← Mike Higgins Home
Custom Code Experiment •

Badger

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 badger 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;

  // ---------- Badger ----------
  const s = Math.sin, c = Math.cos, PI = Math.PI, TAU = PI * 2;
  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;
  const G = 9.81 / 0.83;

  function ik(root, target, a, b, hint) {
    const toT = sub(target, root);
    const d = Math.min(a + b - 1e-4, Math.max(0.02, 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(root, add(mul(u, a * cosA), mul(n, a * sinA)));
  }

  // ---------- Timeline (seconds) ----------
  // waddle along → stop → dig furiously (dirt flying) → pop up, sniff and look around → set off
  const CYCLE = 12.5;
  const V = 0.7, STRIDE_T = 0.7, DUTY = 0.7;
  const speedAt = (tl) => tl < 1 ? V * smooth(tl) : tl < 5 ? V : tl < 5.8 ? V * (1 - smooth((tl - 5) / 0.8)) : 0;
  const digAt = (tl) => tl < 6 ? 0 : tl < 6.4 ? smooth((tl - 6) / 0.4) : tl < 9.8 ? 1 : tl < 10.2 ? 1 - smooth((tl - 9.8) / 0.4) : 0;
  const lookAt = (tl) => tl < 10 ? 0 : tl < 10.4 ? smooth((tl - 10) / 0.4) : tl < 12.1 ? 1 : 1 - smooth((tl - 12.1) / 0.4);
  const SCRAPE_T = 0.36;      // one front-paw scrape

  // Walk: legs move one at a time (left hind, left fore, right hind, right fore)
  const LEGS = [
    { key: 'LH', front: false, side: -1, off: 0 },
    { key: 'LF', front: true,  side: -1, off: 0.25 },
    { key: 'RH', front: false, side: 1,  off: 0.5 },
    { key: 'RF', front: true,  side: 1,  off: 0.75 }
  ];
  function pawAt(phase, off, speed) {
    const q = fract(phase + off);
    const S = speed * STRIDE_T * DUTY, lift = 0.07 * clamp(speed / V, 0, 1);
    if (q < DUTY) return { x: S / 2 - S * (q / DUTY), y: 0, swing: 0 };
    const u = (q - DUTY) / (1 - DUTY);
    return { x: -S / 2 + S * smooth(u), y: lift * s(PI * u), swing: s(PI * u) };
  }
  // Digging: each front paw reaches forward and rakes back under the chest, alternating
  function scrapeAt(t, side) {
    const q = fract(t / SCRAPE_T / 2 + (side > 0 ? 0.5 : 0));
    if (q < 0.55) { const u = q / 0.55; return { x: lerp(-0.08, 0.14, smooth(u)), y: 0.05 * s(PI * u), rake: 0 }; }
    const u = (q - 0.55) / 0.45; return { x: lerp(0.14, -0.08, u * u), y: 0, rake: 1 };
  }

  const HIP_X = -0.3, SH_X = 0.22;

  function buildBadger(st) {
    const Y0 = FLOOR_Y;
    const run = clamp(st.speed / V, 0, 1), dig = st.dig, look = st.look;
    // rolling, shuffling waddle: the body rocks side to side with the footfalls
    const roll = 0.08 * run * s(TAU * st.phase) + 0.03 * dig * s(TAU * st.t / SCRAPE_T / 2);
    const bob = 0.008 * run * c(4 * PI * st.phase);
    const hipY = Y0 + 0.27 + bob + 0.05 * dig, shY = Y0 + 0.25 + bob - 0.07 * dig + 0.04 * look;
    const rollP = (p) => { const cy = Y0 + 0.28, y = p[1] - cy; return [p[0], cy + y * c(roll) - p[2] * s(roll), y * s(roll) + p[2] * c(roll)]; };
    const polys = [];
    const line = (pts, w = 1.6, dim = false) => polys.push({ pts: pts.map(rollP), color: WHITE, w, dim });
    const K = 16;

    // ---- one continuous wedge from rump to snout: broad flat back, no visible neck ----
    const ring = (center, ry, rz, frame, flatTop = 0.75) => {
      const [f, n, z] = frame, pts = [];
      for (let k = 0; k <= K; k++) {
        const a = TAU * k / K, top = c(a) > 0 ? flatTop : 1;
        pts.push(add(center, add(mul(n, ry * c(a) * top), mul(z, rz * s(a)))));
      }
      return pts;
    };
    const BODY = [  // x, ry, rz
      [-0.52, 0.06, 0.08], [-0.46, 0.12, 0.16], [-0.36, 0.155, 0.21], [-0.22, 0.165, 0.225],
      [-0.06, 0.16, 0.215], [0.1, 0.15, 0.19], [0.22, 0.135, 0.16], [0.32, 0.12, 0.135]
    ];
    const bodyFrame = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
    const spineY = (x) => lerp(hipY, shY, clamp((x + 0.36) / 0.68, 0, 1)) + 0.02 + 0.015 * s(clamp((x + 0.52) / 0.84, 0, 1) * PI);
    const rings = BODY.map(([x, ry, rz]) => ring([x, spineY(x), 0], ry, rz, bodyFrame));

    // head: long, tapering cone, carried low and swinging as it sniffs along
    const headA = lerp(-0.28, -0.8, dig) + look * (0.45 + 0.05 * s(st.t * 22)) + 0.04 * run * s(TAU * st.phase * 2);
    const headYaw = 0.14 * run * s(TAU * st.phase) + look * 0.6 * s(st.t * 1.6);
    const hf = rotY([c(headA), s(headA), 0], headYaw), hn = rotY([-s(headA), c(headA), 0], headYaw), hz = rotY([0, 0, 1], headYaw);
    const headBase = [0.42, shY + 0.0 - 0.04 * dig, 0];
    const hp = (u, v, w) => add(headBase, add(add(mul(hf, u), mul(hn, v)), mul(hz, w)));
    const HEAD = [  // u, ry, rz, v
      [0.0, 0.105, 0.12, 0.0], [0.07, 0.095, 0.105, 0.0], [0.14, 0.075, 0.08, -0.008],
      [0.21, 0.055, 0.056, -0.02], [0.27, 0.038, 0.038, -0.03], [0.31, 0.026, 0.026, -0.035]
    ];
    const headRings = HEAD.map(([u, ry, rz, v]) => ring(hp(u, v, 0), ry, rz, [hf, hn, hz], 0.85));
    // thick neck: blend the shoulder ring into the back of the head
    const last = rings[rings.length - 1];
    const neck = [1 / 3, 2 / 3].map(w => last.map((p, k) => add(mul(p, 1 - w), mul(headRings[0][k], w))));
    const all = [...rings, ...neck, ...headRings];

    all.forEach((r, i) => {
      const edge = i === 0 || i === all.length - 1;
      line(r, edge ? 1.3 : 1.0, !edge && i % 2 === 1);
    });
    for (let k = 0; k < K; k += 2) {
      const major = k === 0 || k === 4 || k === 12;
      line(all.map(r => r[k]), major ? 1.4 : 0.9, !major);
    }

    // ---- face: two dark stripes from snout over the eyes to the ears, white blaze between ----
    const hr = (i, a, grow = 1) => {
      const [u, ry, rz, v] = HEAD[i];
      const top = c(a) > 0 ? 0.85 : 1;
      return hp(u, v + ry * grow * c(a) * top, rz * grow * s(a));
    };
    const STRIPE_IN = 0.38, STRIPE_OUT = 1.15;
    for (const sd of [-1, 1]) {
      for (const a of [STRIPE_IN, STRIPE_OUT]) line(HEAD.slice(0, 5).map((_, i) => hr(i, sd * a, 1.01)), 1.8);
      // hatch the stripe so it reads as a solid dark band
      for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) {
        const w = j / 4;
        const lerpP = (a) => { const p0 = hr(i, sd * a, 1.01), p1 = hr(i + 1, sd * a, 1.01); return add(mul(p0, 1 - w), mul(p1, w)); };
        line([lerpP(STRIPE_IN), lerpP(STRIPE_OUT)], 1.1);
      }
      // small eye inside the stripe
      const e = hr(2, sd * 0.78, 1.03);
      line([add(e, mul(hf, -0.012)), add(e, mul(hf, 0.012))], 2.6);
      // tiny rounded ear with a pale rim, set low at the back of the head
      const ear = [];
      for (let k = 0; k <= 8; k++) { const q = PI * k / 8; ear.push(add(hr(0, sd * 1.15, 1.02), add(mul(hf, -0.03 * c(q)), add(mul(hn, 0.035 * s(q)), mul(hz, sd * 0.02 * s(q)))))); }
      line(ear, 1.4);
    }
    // blunt nose pad at the tip
    {
      const tip = hp(0.325, -0.035, 0), pts = [];
      for (let k = 0; k <= 10; k++) { const q = TAU * k / 10; pts.push(add(tip, add(mul(hn, 0.02 * c(q)), mul(hz, 0.026 * s(q))))); }
      line(pts, 1.6);
    }

    // ---- long guard hairs: a shaggy fringe hanging down the flanks almost to the ground ----
    const sway = -0.03 * run * c(TAU * st.phase), flutter = st.t * 7;
    for (let i = 1; i < BODY.length - 1; i++) for (const sd of [-1, 1]) for (const a of [1.95, 2.25]) {
      for (const w of [0, 0.5]) {
        const x0 = lerp(BODY[i][0], BODY[i + 1][0], w);
        const r0 = rings[i][Math.round(sd * a / TAU * K + K) % K], r1 = rings[i + 1][Math.round(sd * a / TAU * K + K) % K];
        const top = add(mul(r0, 1 - w), mul(r1, w));
        const len = top[1] - (Y0 + 0.06);
        if (len < 0.02) continue;
        const f = 0.012 * s(flutter + x0 * 20 + sd);
        line([top, [top[0] + sway * 0.5 + f, top[1] - len * 0.55, top[2] + sd * 0.025], [top[0] + sway + f * 2, Y0 + 0.06, top[2] + sd * 0.04]], 0.8, true);
      }
    }
    // grizzled back: short strokes lying along the fur
    for (let i = 1; i < BODY.length - 1; i++) for (const k of [1, 2, 15, 14]) {
      const p = rings[i][k];
      line([p, add(p, [-0.05, 0.01, 0])], 0.8, true);
    }

    // ---- short pale tail ----
    const tb = [-0.52, spineY(-0.52) + 0.04, 0];
    for (const dz of [-0.025, 0, 0.025]) line([tb, [-0.6, tb[1] + 0.035, dz + 0.01 * s(TAU * st.phase)], [-0.64, tb[1] + 0.02, dz * 1.3]], 1.2);

    // ---- short, powerful legs (mostly hidden by the fringe) with long front claws ----
    for (const L of LEGS) {
      const rootX = L.front ? SH_X : HIP_X;
      let paw;
      if (L.front && dig > 0) {
        const sc = scrapeAt(st.t, L.side), w = pawAt(st.phase, L.off, st.speed);
        paw = { x: lerp(w.x, sc.x, dig), y: lerp(w.y, sc.y, dig) };
      } else paw = pawAt(st.phase, L.off, st.speed);
      const spread = 0.15 + (L.front ? 0 : 0.04 * dig);
      const root = [rootX, (L.front ? shY - 0.04 : hipY - 0.03), L.side * 0.12];
      const pawP = [rootX + 0.02 + paw.x, Y0 + 0.02 + paw.y, L.side * spread];
      const wrist = add(pawP, L.front ? [0.0, 0.035, 0] : [-0.05, 0.06, 0]);
      const mid = L.front ? ik(root, wrist, 0.12, 0.12, [-1, 0, 0]) : ik(root, wrist, 0.12, 0.12, [1, 0, 0]);
      line([root, mid, wrist, pawP], 2.2);
      const pts = [];
      for (let k = 0; k <= 10; k++) { const a = TAU * k / 10; pts.push(add(pawP, [0.03 + 0.04 * c(a), -0.01, 0.032 * s(a)])); }
      line(pts, 1.2);
      // front feet turn slightly in; long digging claws
      if (L.front) for (const dz of [-0.022, -0.007, 0.008, 0.022]) line([add(pawP, [0.07, -0.01, dz]), add(pawP, [0.12, -0.02, dz - L.side * 0.012])], 1.3);
      else for (const dz of [-0.018, 0, 0.018]) line([add(pawP, [0.07, -0.01, dz]), add(pawP, [0.09, -0.015, dz])], 1.1);
    }
    return polys;
  }

  // ---------- Dirt: real ballistic arcs that pile into a mound ----------
  const dirt = [];                  // flying clods (floor coordinates)
  const piles = [];                 // mounds and holes left in the floor
  function spawnDirt(fx, z, n) {
    for (let i = 0; i < n; i++) dirt.push({
      fx, y: FLOOR_Y + 0.05, z: z + (Math.random() - 0.5) * 0.06,
      vx: -(1.2 + Math.random() * 0.9), vy: 0.9 + Math.random() * 0.9, vz: (Math.random() - 0.5) * 0.5,
      landed: false, t: 0
    });
  }
  function stepDirt(dt, pile) {
    for (const d of dirt) {
      if (d.landed) { d.t += dt; continue; }
      d.vy -= G * dt;
      d.fx += d.vx * dt; d.y += d.vy * dt; d.z += d.vz * dt;
      const ground = FLOOR_Y + (pile ? pileHeight(pile, d.fx, d.z) : 0);
      if (d.y <= ground && d.vy < 0) {
        d.y = ground; d.landed = true;
        if (pile) pile.count++;
      }
    }
    while (dirt.length > 260) dirt.shift();
  }
  function pileHeight(p, fx, z) {
    const r = 0.1 + 0.012 * Math.sqrt(p.count) * 4, h = Math.min(0.14, 0.004 * Math.sqrt(p.count) * 4);
    const d = Math.hypot(fx - p.mx, z - p.mz) / r;
    return d < 1 ? h * (1 - d * d) : 0;
  }

  // ---------- Floor ----------
  const FLOOR_R = 1.25, GRID = 0.25;
  function buildFloor(travelled) {
    const polys = [];
    const ring = [];
    for (let k = 0; k <= 56; k++) { const a = TAU * k / 56; ring.push([FLOOR_R * c(a), FLOOR_Y, FLOOR_R * s(a)]); }
    polys.push({ pts: ring, color: FLOOR, w: 1.0 });
    const shift = ((travelled % GRID) + GRID) % GRID;
    for (let x = -FLOOR_R - shift + GRID; x < FLOOR_R; x += GRID) {
      const h = Math.sqrt(Math.max(0, FLOOR_R * FLOOR_R - x * x));
      polys.push({ pts: [[x, FLOOR_Y, -h], [x, FLOOR_Y, h]], color: FLOOR, w: 0.7, dim: true });
    }
    for (let z = -1.0; z <= 1.01; z += GRID) {
      const h = Math.sqrt(Math.max(0, FLOOR_R * FLOOR_R - z * z));
      polys.push({ pts: [[-h, FLOOR_Y, z], [h, FLOOR_Y, z]], color: FLOOR, w: 0.7, dim: true });
    }
    const inside = (x, z) => Math.hypot(x, z) < FLOOR_R - 0.05;
    for (const p of piles) {
      const hx = p.hx - travelled, mx = p.mx - travelled;
      // hole: rim plus rings stepping down into it
      if (inside(hx, p.hz)) for (const [r, dy] of [[1, 0], [0.7, -0.03], [0.4, -0.06]]) {
        const pts = [], R = 0.13 * p.depth * r;
        if (R < 0.01) continue;
        for (let k = 0; k <= 20; k++) { const a = TAU * k / 20; pts.push([hx + R * 1.3 * c(a), FLOOR_Y + dy * p.depth, p.hz + R * s(a)]); }
        polys.push({ pts, color: WHITE, w: 1.1, alpha: p.fade * (r === 1 ? 0.9 : 0.5) });
      }
      // mound: a low dome of dug-up earth
      if (p.count > 2 && inside(mx, p.mz)) {
        const r = 0.1 + 0.012 * Math.sqrt(p.count) * 4, h = Math.min(0.14, 0.004 * Math.sqrt(p.count) * 4);
        for (const f of [1, 0.7, 0.4]) {
          const pts = [], y = FLOOR_Y + h * (1 - f * f);
          for (let k = 0; k <= 20; k++) { const a = TAU * k / 20; pts.push([mx + r * f * c(a), y, p.mz + r * f * 0.9 * s(a)]); }
          polys.push({ pts, color: WHITE, w: 1.0, alpha: p.fade * 0.7, dim: f !== 1 });
        }
        for (let k = 0; k < 6; k++) {
          const a = TAU * k / 6, pts = [];
          for (let j = 0; j <= 4; j++) { const f = j / 4; pts.push([mx + r * f * c(a), FLOOR_Y + h * (1 - f * f), p.mz + r * f * 0.9 * s(a)]); }
          polys.push({ pts, color: WHITE, w: 0.8, alpha: p.fade * 0.5 });
        }
      }
    }
    // clods of dirt: short streaks in flight, tiny crosses once landed
    for (const d of dirt) {
      const x = d.fx - travelled;
      if (!inside(x, d.z)) continue;
      const a = d.landed ? Math.max(0, 1 - d.t / 4) : 1;
      if (a <= 0) continue;
      if (d.landed) polys.push({ pts: [[x - 0.012, d.y + 0.004, d.z], [x + 0.012, d.y + 0.004, d.z]], color: WHITE, w: 1.4, alpha: 0.6 * a });
      else polys.push({ pts: [[x, d.y, d.z], [x - d.vx * 0.018, d.y - d.vy * 0.018, d.z - d.vz * 0.018]], color: WHITE, w: 1.8 });
    }
    return polys;
  }

  // ---------- Projection ----------
  // The camera follows the dancer, so he stays centred and the floor glides beneath him
  const CENTER_Y = -0.81;
  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.42;
    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 travelled = 0, gaitPhase = 0, prevTl = null, lastScrape = {};
  let pile = null;

  function frame(now) {
    if (W === 0 || H === 0) {
      resize();
      if (W === 0 || H === 0) { last = now; requestAnimationFrame(frame); return; }
    }
    const rdt = Math.min(50, now - last) / 1000; last = now;
    const dt = reduced ? rdt / 3 : rdt;
    orbit += rdt * (reduced ? 0.08 : 0.3);
    const t = now / 1000, st = t * (reduced ? 1 / 3 : 1);
    const tl = (st % CYCLE + CYCLE) % CYCLE;

    const speed = speedAt(tl), dig = digAt(tl), look = lookAt(tl);
    gaitPhase += dt / STRIDE_T * (0.3 + 0.7 * speed / V);
    travelled += speed * dt;

    // start a new hole + mound each time digging begins
    if (prevTl !== null && prevTl < 6 && tl >= 6) {
      pile = { hx: SH_X + 0.12 + travelled, hz: 0, mx: HIP_X - 0.45 + travelled, mz: 0, count: 0, depth: 0, fade: 1 };
      piles.push(pile);
      if (!reduced) { glitch = 10; glitchStrength = 0.8; }
    }
    for (const p of piles) {
      if (p === pile && dig > 0.5) p.depth = Math.min(1, p.depth + dt * 0.3);
      if (Math.abs(p.hx - travelled) > 2.6) p.fade = 0;
    }
    while (piles.length && piles[0].fade === 0) piles.shift();

    // every rake of a front paw flings a spray of dirt back between the hind legs
    if (dig > 0.8) for (const sd of [-1, 1]) {
      const r = scrapeAt(st, sd).rake;
      if (r && !lastScrape[sd]) spawnDirt(SH_X + travelled, sd * 0.1, 5 + Math.floor(Math.random() * 4));
      lastScrape[sd] = r;
    }
    stepDirt(dt, pile);

    // head pops up to look around: glitch
    if (!reduced && prevTl !== null && prevTl < 10.2 && tl >= 10.2) { glitch = 12; glitchStrength = 1; }
    else if (!reduced && glitch <= 0 && Math.random() < 0.015) { glitch = 4 + Math.floor(Math.random() * 8); glitchStrength = 0.4 + Math.random() * 0.5; }
    prevTl = tl;
    const g = glitch > 0;
    if (g) glitch--;
    const shX = 0, shY = 0, howl = 0;

    const view = { yaw: orbit, pitch: 0.3 + Math.sin(t * 0.7) * 0.04 };
    focusX = 0; focusZ = 0;
    const polys = buildFloor(travelled).concat(buildBadger({ phase: gaitPhase, speed, dig, look, t: st }));

    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>