Custom Code Experiment •
Dancers (Combined) 1
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>Dancers (Combined) 1</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 (its speed follows the music's tempo) */
.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 ringEl = document.querySelector('.ring');
const arcEl = document.querySelector('.ring .arc');
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();
// ---------- Math helpers ----------
const s = Math.sin, c = Math.cos, PI = Math.PI, TAU = PI * 2, H_PI = PI / 2;
const add = (a, b) => [a[0]+b[0], a[1]+b[1], a[2]+b[2]];
const sub = (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 dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
const mag = (a) => Math.hypot(a[0], a[1], a[2]);
const norm = (a) => mul(a, 1 / (mag(a) || 1));
const mix3 = (a, b, w) => add(a, mul(sub(b, a), w));
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 rotX = ([x, y, z], a) => [x, y*c(a) - z*s(a), y*s(a) + z*c(a)];
const rotY = ([x, y, z], a) => [x*c(a) + z*s(a), y, -x*s(a) + z*c(a)];
const rotZ = ([x, y, z], a) => [x*c(a) - y*s(a), x*s(a) + y*c(a), z];
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, w) => a + (b - a) * w;
const smooth = (x) => { x = clamp(x, 0, 1); return x * x * (3 - 2 * x); };
const fract = (x) => x - Math.floor(x);
const mod = (x, m) => ((x % m) + m) % m;
const wrap = (a) => mod(a + PI, TAU) - PI;
const easeOutBack = (x) => 1 + 2.4 * Math.pow(x - 1, 3) + 1.4 * Math.pow(x - 1, 2);
// 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);
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;
}
// rotate v by the rotation that turns unit vector `from` onto unit vector `to`
function turnLike(v, from, to) {
const ax = cross(from, to), sn = mag(ax), cs = dot(from, to);
if (sn < 1e-6) return v;
const k = mul(ax, 1 / sn);
return add(add(mul(v, cs), mul(cross(k, v), sn)), mul(k, dot(k, v) * (1 - cs)));
}
// two-bone IK: returns [knee, ankle]
function ik(root, target, a, b, hint) {
const toT = sub(target, root);
const d = Math.min(a + b - 1e-4, Math.max(0.05, mag(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))), add(root, mul(u, d))];
}
// ---------- One skeleton and one dance floor for every style ----------
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, ANK = 0.06;
const FLOOR_Y = -1.0;
const N = 7, TILE = 0.26, HALF_W = N * TILE / 2; // 7 × 7 tiles
// Foot, relative to the ankle when flat: heel, ball, and a toe that can bend at the ball
const HEEL_Z = -0.025, BALL_Z = 0.09, TOE_LEN = 0.05, BALL0 = [0, -ANK, BALL_Z];
// A pose drives the legs two ways: joint angles (robot, breaking, Irish) or foot targets
// solved with IK (moonwalk, drum & bass). `ik` blends between them, so styles melt into each other.
const A = (abd, fwd, eAbd, eFwd) => ({ abd, fwd, eAbd, eFwd });
// `toe` points the foot along the shin: 0 = flat, 1 = fully pointed
const L = (abd, fwd, knee, toe = 0) => ({ abd, fwd, knee, toe });
// ankle target above the floor; `point` tips the foot toes-down, `curl` bends the toes back flat
const F = (x, y, z, point = 0, curl = 0) => ({ x, y, z, point, curl });
const STANCE = L(0.1, 0, 0), HANG = A(0.06, 0, 0, 0);
const DEF = {
tx: 0, tz: 0, yaw: 0, spin: 0, pitch: 0, roll: 0, air: 0,
leanFwd: 0, leanSide: 0, torsoX: 0, headTilt: 0, headTurn: 0, headNod: 0,
armR: HANG, armL: HANG, legR: STANCE, legL: STANCE,
ik: 0, hipH: 0.82, kneeOut: 0.35, footTurn: 0.2,
fR: F(0.16, ANK, 0.02), fL: F(-0.16, ANK, 0.02), slideR: 0, slideL: 0
};
const P = (o) => Object.assign({}, DEF, o);
// ================= Act 1: the robot (100 BPM) =================
// Poses are hit on the beat: hold still, then snap with a tiny overshoot.
const BOX = A(0.15, 0, 0, H_PI);
const UP = A(0.15, 0, 0, PI);
const FRONT = A(0.05, H_PI, 0, H_PI);
const GOAL = A(H_PI, 0, H_PI, 0);
const DROP = A(H_PI, 0, -H_PI, 0);
const POINT = A(0.1, H_PI, 0, 0);
const SNAP = 0.2;
const snap = (f) => (f < SNAP ? easeOutBack(f / SNAP) : 1);
const keyed = (keys) => (b) => {
const x = Math.max(0, b), i = Math.floor(x) % keys.length;
const prev = x < 1 ? keys[0] : keys[(i - 1 + keys.length) % keys.length];
return blend(prev, keys[i], snap(fract(x)));
};
const robotArms = keyed([
P({ armR: BOX, armL: BOX }),
P({ armR: UP, armL: BOX, headTurn: 0.6 }),
P({ armR: BOX, armL: UP, headTurn: -0.6 }),
P({ armR: FRONT, armL: FRONT, yaw: 0.45 }),
P({ armR: GOAL, armL: GOAL, yaw: -0.45 }),
P({ armR: DROP, armL: DROP, headNod: 0.3 }),
P({ armR: POINT, armL: BOX, headTurn: 0.5, yaw: 0.3 }),
P({ armR: BOX, armL: POINT, headTurn: -0.5, yaw: -0.3 })
]);
// Arm wave in strobe: a ripple runs hand → shoulder → shoulder → hand
const armWave = (b) => {
const q = Math.floor(Math.max(0, b) * 6) / 6;
const w = q * PI;
const r = (i) => Math.max(0, s(w - i * 0.8));
const dir = Math.floor(q / 2) % 2 === 0 ? 1 : -1;
const j = (i) => r(dir > 0 ? i : 3 - i);
const give = j(1) + j(2);
return P({
armR: A(H_PI - 0.1 + 0.35 * j(1), 0.1, 0.9 * j(0) - 0.2 * j(1), 0),
armL: A(H_PI - 0.1 + 0.35 * j(2), 0.1, 0.9 * j(3) - 0.2 * j(2), 0),
leanSide: 0.08 * (j(1) - j(2)), headTilt: 0.15 * (j(2) - j(1)),
legR: L(0.12, 0.08 * give, 0.16 * give), legL: L(0.12, 0.08 * give, 0.16 * give)
});
};
// Tick walk: stiff marching on the spot, a quarter-turn every two beats
const tickWalk = (() => {
const keys = [], lift = L(0.08, 0.9, 1.57);
const arm = (f) => A(0.12, f, 0, H_PI);
for (let n = 0; n < 8; n++) {
const up = n % 2 === 0, right = n % 4 === 0;
keys.push(P({
legR: up && right ? lift : STANCE, legL: up && !right ? lift : STANCE,
armR: arm(up ? (right ? -0.5 : 0.5) : 0), armL: arm(up ? (right ? 0.5 : -0.5) : 0),
yaw: Math.floor(n / 2) * H_PI, headTurn: up ? (right ? -0.3 : 0.3) : 0
}));
}
return keyed(keys);
})();
// Power down joint by joint… and reboot (into the breaking)
const slump = (d) => L(0.1, 0.45 * d, 0.9 * d);
const hangFwd = (f) => A(0.06, f, 0, 0.2);
const REBOOT_POSE = P({ armR: GOAL, armL: GOAL, headNod: -0.15 });
const powerDown = keyed([
P({ armR: BOX, armL: BOX }),
P({ armR: BOX, armL: BOX, headNod: 0.55 }),
P({ armR: HANG, armL: BOX, headNod: 0.55, leanSide: 0.08 }),
P({ armR: HANG, armL: HANG, headNod: 0.6, leanFwd: 0.25, headTilt: 0.2 }),
P({ armR: hangFwd(0.6), armL: hangFwd(0.6), headNod: 0.8, leanFwd: 0.6, legR: slump(0.6), legL: slump(0.6), headTilt: 0.25 }),
P({ armR: hangFwd(0.9), armL: hangFwd(0.9), headNod: 0.9, leanFwd: 0.9, legR: slump(1), legL: slump(1), headTilt: 0.3 }),
REBOOT_POSE, REBOOT_POSE
]);
// ================= Act 2: the moonwalk (100 BPM) =================
// A square traced on the tile grid with moonwalks and sidewalks, one tile per beat,
// with a spin and toe stand at every corner. The route starts and ends in the centre.
const STEP = TILE; // body travel per beat
const MOON_TURN = 0.08; // feet nearly parallel so they can slide
// `o` is the foot's offset along the travel direction (body frame), `heel` how far the heel is raised.
// The planted foot sits still on its toe while the body glides past; the other slides back flat.
function glideFoot(phi) {
phi = mod(phi, 1);
if (phi < 0.5) return { o: STEP / 2 - 2 * STEP * phi, heel: 0.9 * smooth(phi / 0.07), slide: 0 };
const q = phi - 0.5;
return { o: -STEP / 2 + 2 * STEP * q, heel: 0.9 * (1 - smooth(q / 0.07)), slide: q > 0.04 ? 1 : 0 };
}
// ball of the foot on the floor, heel raised about it → ankle target for the IK
const footAt = (side, o, dir, h) => {
const ball = [side * 0.11 + dir[0] * o, 0, dir[2] * o];
const a = sub(ball, rotY(rotX(BALL0, h), side * MOON_TURN));
return F(a[0], a[1], a[2], h, h);
};
const glide = (from, to, yaw, dir) => (b, n) => {
const u = clamp(b / n, 0, 1), moon = dir[2] !== 0;
const R = glideFoot(b / 2), Lf = glideFoot(b / 2 + 0.5);
const sw = s(b * PI);
return P({
ik: 1, kneeOut: 0.05, footTurn: MOON_TURN,
tx: lerp(from[0], to[0], u), tz: lerp(from[1], to[1], u), yaw,
fR: footAt(1, R.o, dir, R.heel), fL: footAt(-1, Lf.o, dir, Lf.heel),
hipH: 0.84 - 0.015 * Math.abs(sw),
leanFwd: moon ? 0.1 : 0.04, headTurn: moon ? 0 : -dir[0] * 0.5, headNod: moon ? 0.05 : 0,
armR: A(0.18, moon ? 0.25 * sw : 0.1, 0, 0.5), armL: A(0.18, moon ? -0.25 * sw : 0.1, 0, 0.5),
slideR: R.slide, slideL: Lf.slide
});
};
// spin on the toes (travelling if from ≠ to), then hit a toe stand on the beat
const spinTo = (from, to, yaw0, turn) => (b) => {
const spinU = smooth(b), stand = smooth((b - 1) / 0.15);
const h = 0.9 + 0.45 * stand;
const out = A(1.3 * (1 - stand), 0.1, 0.2, 0.3);
return P({
ik: 1, kneeOut: 0.05, footTurn: MOON_TURN,
tx: lerp(from[0], to[0], spinU), tz: lerp(from[1], to[1], spinU), yaw: yaw0 + turn * spinU,
fR: footAt(1, 0, [0, 0, 1], h), fL: footAt(-1, 0, [0, 0, 1], h),
hipH: 0.88 - 0.12 * stand, leanFwd: -0.05 * stand, headTurn: 0.6 * stand, headNod: -0.1 * stand,
armR: blend(out, A(2.3, 0.35, 0.15, 0), stand), armL: blend(out, A(0.12, 0.25, 0, 0.9), stand)
});
};
const Q = 2 * TILE; // corners sit on tile centres
const CNR = [[-Q, Q], [-Q, -Q], [Q, -Q], [Q, Q]], MID = [0, 0];
const MOON_ROUTE = [
{ fn: spinTo(MID, CNR[0], -H_PI, TAU + H_PI), len: 2, toe: true }, // spins out of the tick walk; the hat drops on
{ fn: glide(CNR[0], CNR[1], 0, [0, 0, -1]), len: 4 }, // moonwalk
{ fn: spinTo(CNR[1], CNR[1], 0, TAU), len: 2, toe: true },
{ fn: glide(CNR[1], CNR[2], 0, [1, 0, 0]), len: 4 }, // sidewalk
{ fn: spinTo(CNR[2], CNR[2], 0, TAU + PI), len: 2, toe: true },
{ fn: glide(CNR[2], CNR[3], PI, [0, 0, -1]), len: 4 }, // moonwalk
{ fn: spinTo(CNR[3], CNR[3], PI, TAU + PI), len: 2, toe: true },
{ fn: glide(CNR[3], CNR[0], 0, [-1, 0, 0]), len: 4 }, // sidewalk
{ fn: spinTo(CNR[0], MID, 0, TAU), len: 2, toe: true } // spins back to the centre
].map((m) => Object.assign({ sec: 'moon', fade: 0.3 }, m));
MOON_ROUTE[MOON_ROUTE.length - 1].ease = 'snap'; // …and snaps back into robot mode
const TOE_STEPS = [];
MOON_ROUTE.reduce((at, m) => { if (m.toe) TOE_STEPS.push((at + 1) * 4); return at + m.len; }, 0);
// ---------- Gravity (shared by every jump) ----------
// The figure is about 2 units tall (~1.7 m), so 1 unit ≈ 0.83 m and g ≈ 11.8 units/s².
// Jumps use the music's real tempo, so reduced motion slows them without making them higher.
const G = 9.81 / 0.83;
function jump(b, period, height, bpm) {
const flight = Math.min(period * 0.75, Math.sqrt(8 * height / G) / (60 / bpm));
const ground = period - flight, ph = mod(b, period);
const takeoff = ground / 2, landing = takeoff + flight;
if (ph >= takeoff && ph <= landing) {
const u = (ph - takeoff) / flight;
return { air: 4 * height * u * (1 - u), u, flying: true, crouch: 0 };
}
// on the ground: bend on landing to absorb it, press down again to take off
const g = ph < takeoff ? ph + ground / 2 : ph - landing;
return { air: 0, u: 0, flying: false, crouch: s(PI * g / ground) };
}
// which side is working (1 = right): swaps every `period` beats, crossing over smoothly
// around the change instead of snapping
const swap = (b, period = 1) => 0.5 + 0.5 * clamp(3 * s(PI * b / period), -1, 1);
const bump = (b) => Math.abs(s(b * PI)); // 0..1 once per beat
// ================= Act 3: breaking (108 BPM) =================
// eases from rest, spins up, eases back to rest; ends on a whole number of turns
const spinCurve = (u, turns) => TAU * turns * (u - s(TAU * u) / TAU);
// Toprock: cross-steps that swap sides through the beat instead of jumping between them
const toprock = (b) => {
const sw = s(PI * b);
const w = 0.5 + 0.5 * clamp(sw * 1.8, -1, 1);
const step = 1 - Math.abs(2 * w - 1);
const cross = L(-0.22, 0.35, 0.35), plant = L(0.12, -0.05, 0.15 + 0.2 * Math.abs(sw));
const open = A(1.25, 0.2, 0.5, 0), guard = A(0.35, 0.9, 0, 1.5);
const lift = (l) => L(l.abd, l.fwd + 0.1 * step, l.knee + 0.3 * step);
return P({
armR: blend(guard, open, w), armL: blend(open, guard, w),
legR: lift(blend(plant, cross, w)), legL: lift(blend(cross, plant, w)),
leanFwd: 0.12, yaw: 0.35 * sw, headTilt: -0.1 * sw
});
};
// Windmill: rolling across the upper back, legs in a V
const windmill = (b, n) => {
const ang = spinCurve(clamp(b / n, 0, 1), 3);
return P({
armR: A(0.3, 0.6, -0.6, 1.2), armL: A(0.3, 0.6, -0.6, 1.2),
legR: L(0.85, 0.15, 0), legL: L(0.85, 0.15, 0),
pitch: 1.85, spin: ang, yaw: ang / 3
});
};
// Headspin: upside down, legs in a V, arms out for balance
const headspin = (b, n) => P({
armR: A(1.35, 0.1, 0.4, 0), armL: A(1.35, 0.1, 0.4, 0),
legR: L(0.5, 0, 0.05), legL: L(0.5, 0, 0.05),
pitch: PI + 0.06 * s(b * PI / 2), spin: spinCurve(clamp(b / n, 0, 1), 5)
});
// One-hand freeze: tipped sideways on a straight arm, legs tucked
const freeze = (side) => (b) => {
const r = 1.15, p = 0.45, tremble = 0.015 * s(b * 23);
const support = A(r, p, 0, 0), flair = A(2.1, 0.3, 0.4, 0);
const tuckA = L(0.3, 1.4, 1.8), tuckB = L(0.2, 0.9, 1.2);
return P({
armL: side < 0 ? support : flair, armR: side < 0 ? flair : support,
legR: side < 0 ? tuckA : tuckB, legL: side < 0 ? tuckB : tuckA,
pitch: p + tremble, roll: -side * r, yaw: side * 0.5, headTilt: side * 0.2
});
};
// ================= Act 4: disco (116 BPM) =================
// Disco point: one arm shoots up to the corner then pulls down across; sides swap every two beats
const discoPoint = (b) => {
const r = swap(b, 2), side = 2 * r - 1;
const up = 0.5 - 0.5 * c(mod(b, 2) * PI);
const point = A(0.2 + up * 2.3, 0.25, 0, 0), hip = A(0.75, -0.1, -1.6, 0);
const knee = 0.2 + 0.35 * bump(b);
return P({
armR: blend(hip, point, r), armL: blend(point, hip, r),
legR: L(0.12 + 0.1 * r, 0, knee), legL: L(0.22 - 0.1 * r, 0, knee),
leanSide: side * 0.12 * up, yaw: side * 0.25, headTilt: side * 0.2 * up
});
};
// Hands in the air, swaying
const handsUp = (b) => {
const sway = s(b * PI / 2), k = bump(b);
return P({
armR: A(2.55 + sway * 0.3, 0.1, 0.2 + 0.3 * k, 0), armL: A(2.55 - sway * 0.3, 0.1, 0.2 + 0.3 * k, 0),
legR: L(0.18, 0, 0.15 + 0.4 * k), legL: L(0.18, 0, 0.15 + 0.4 * k),
leanSide: sway * 0.18, headTilt: -sway * 0.25
});
};
// Kicks with swinging arms
const discoKicks = (b) => {
const r = swap(b), a = 2 * r - 1, k = bump(b);
const kick = L(0.05, 1.1 * k, 2.42 * k * (1 - k)), stand = L(0.08, -0.1 * k, 0.25 * k);
return P({
armR: A(0.35, -a * 0.9 * k, 0, 1.3), armL: A(0.35, a * 0.9 * k, 0, 1.3),
legR: blend(stand, kick, r), legL: blend(kick, stand, r),
leanFwd: -0.12 * k, yaw: -a * 0.2 * k
});
};
// Spin on one foot with arms out: winds up from rest and settles after one full turn
const discoSpin = (b, n) => {
const k = bump(b);
return P({
armR: A(1.45 + 0.2 * s(b * PI), 0, 0.3, 0), armL: A(1.45 - 0.2 * s(b * PI), 0, 0.3, 0),
legR: L(0.1, 0, 0.1 + 0.2 * k), legL: L(0.05, 0.35, 0.9),
yaw: spinCurve(clamp(b / n, 0, 1), 1), headTilt: 0.15
});
};
// ================= Act 5: Cossack dance, the hopak (116 BPM) =================
// Same foot-target IK as the drum & bass, plus `px`: the pelvis slides sideways while the feet stay put.
const COSSACK_BPM = 116;
const cossack = ({ lean = 0, px = 0, ...o }) => {
const p = P(Object.assign({ ik: 1, hipH: 0.84, kneeOut: 0.3, footTurn: 0.25 }, o, { leanFwd: lean }));
p.tx = px * c(p.yaw); p.tz = -px * s(p.yaw);
p.fR = Object.assign({}, p.fR, { x: p.fR.x - px });
p.fL = Object.assign({}, p.fL, { x: p.fL.x - px });
return p;
};
const HIPS = A(0.75, -0.1, -1.6, 0); // hands on hips
const CROSS_R = A(0.3, 1.15, -H_PI - 0.3, -1.15), CROSS_L = A(0.3, 1.3, -H_PI - 0.3, -1.3); // arms folded
const OPEN = A(1.45, 0.15, 0.15, 0); // arms flung out
// Stamping: hands on hips, knees lifting and stamping down on the beat
const stamp = (b) => {
const k = s(PI * Math.pow(fract(b), 0.8)), right = mod(Math.floor(b), 2) === 0; // lift, then drive down fast
const up = (x) => F(x, ANK + 0.24 * k, 0.1 * k, 0.3 * k), planted = (x) => F(x, ANK, 0);
return cossack({
fR: right ? up(0.13) : planted(0.13), fL: right ? planted(-0.13) : up(-0.13),
hipH: 0.83 - 0.03 * k, armR: HIPS, armL: HIPS, lean: -0.06,
yaw: 0.45 * s(b * PI / 4), headTurn: -0.3 * s(b * PI / 4)
});
};
// Prisiadka: deep squat, arms folded, kicking the legs out in turn with a little hop to switch
const prisiadka = (b) => {
const j = jump(b, 1, 0.05, COSSACK_BPM);
const kR = 0.5 + 0.5 * clamp(c(PI * b) * 3.5, -1, 1), kL = 1 - kR; // swaps legs mid-hop
const leg = (side, k) => F(side * (0.15 + 0.03 * k), lerp(0.1, ANK, k), lerp(0.05, 0.78, k), lerp(0.5, -0.6, k));
return cossack({
fR: leg(1, kR), fL: leg(-1, kL), hipH: 0.3 - 0.03 * j.crouch, air: j.air,
armR: CROSS_R, armL: CROSS_L, lean: -0.12, headNod: -0.1, kneeOut: 0.6
});
};
// Polzunets: feet planted wide, the body slides low from side to side,
// bending over one leg while the other stretches out straight, arms open
const polzunets = (b) => {
const side = c(b * PI / 2), bendR = (1 + side) / 2; // +1 over the right foot, −1 over the left
return cossack({
px: 0.34 * side, hipH: 0.34 + 0.1 * (1 - Math.abs(side)),
fR: F(0.55, ANK, 0.08, lerp(-0.6, 0.3, bendR)), fL: F(-0.55, ANK, 0.08, lerp(-0.6, 0.3, 1 - bendR)),
armR: OPEN, armL: OPEN, leanSide: -0.22 * side, headTurn: -0.5 * side, kneeOut: 0.6
});
};
// Straddle jumps: crouch, spring up, legs split wide to the sides, hands reaching for the toes
const straddle = (b) => {
const j = jump(b, 2, 0.55, COSSACK_BPM);
const sp = j.flying ? Math.min(1, s(PI * j.u) * 1.7) : 0;
const hipH = 0.84 - 0.3 * j.crouch;
const leg = (side) => F(side * lerp(0.13, 0.85, sp), lerp(ANK, hipH + 0.05, sp), lerp(0, 0.28, sp), lerp(0, 0.9, sp));
const reach = A(lerp(0.3, 1.25, sp), lerp(0.2 + 0.6 * j.crouch, 0.5, sp), 0, lerp(0.3, 0, sp));
return cossack({
fR: leg(1), fL: leg(-1), hipH, air: j.air,
armR: reach, armL: reach, lean: 0.25 * j.crouch + 0.35 * sp, headNod: -0.15 * sp, kneeOut: 0.35
});
};
// ================= Act 6: Irish dance (118 BPM) =================
// Upper body rigid, arms locked at the sides, up on the balls of turned-out feet;
// all the action is below the hips.
const IRISH_BPM = 118;
const LOCKED = A(0.07, 0, 0, 0);
const irish = (o) => P(Object.assign({ armR: LOCKED, armL: LOCKED, leanFwd: -0.04, footTurn: 0.45 }, o));
const soften = (l, cr) => L(l.abd, l.fwd + 0.25 * cr, l.knee + 0.55 * cr, l.toe); // bend to land and take off
// Sevens: travelling sideways with crossed feet, a small hop and a heel flick behind on each beat
const sevens = (b) => {
const j = jump(b, 1, 0.05, IRISH_BPM), k = j.flying ? s(PI * j.u) : 0, r = swap(b);
const lift = soften(L(-0.12, -0.45 * k, 2.0 * k, 0.6 + 0.4 * k), j.crouch);
const stand = soften(L(0.04, 0, 0, 0.6), j.crouch);
return irish({
legR: blend(stand, lift, r), legL: blend(lift, stand, r),
air: j.air, tx: 0.32 * s(b * TAU / 8), yaw: 0.25 * c(b * TAU / 8)
});
};
// High kicks: a straight leg kicked up in front while hopping on the other foot
const kicks = (b) => {
const j = jump(b, 1, 0.07, IRISH_BPM), k = s(PI * fract(b)), r = swap(b);
const kick = soften(L(0.02, 1.75 * k, 0.15 * (1 - k), 0.6 + 0.4 * k), j.crouch * (1 - k));
const stand = soften(L(0.04, 0, 0, 0.6), j.crouch);
return irish({ legR: blend(stand, kick, r), legL: blend(kick, stand, r), air: j.air, leanFwd: -0.04 - 0.06 * k });
};
// Leaps: split jumps; the legs open fast after take-off and close before landing
const leaps = (b) => {
const j = jump(b, 2, 0.42, IRISH_BPM), r = swap(b, 2);
const sp = j.flying ? Math.min(1, s(PI * j.u) * 1.6) : 0;
const front = soften(L(0.03, 1.25 * sp, 0.1 * sp, 0.6 + 0.4 * sp), j.crouch);
const back = soften(L(0.03, -0.95 * sp, 0.35 * sp, 0.6 + 0.4 * sp), j.crouch);
return irish({
legR: blend(back, front, r), legL: blend(front, back, r),
air: j.air, tz: 0.18 * s(b * TAU / 8), yaw: 0.2 * (2 * r - 1)
});
};
// Heel clicks: jump and knock the heels together out to one side at the top of the jump
const clicks = (b) => {
const j = jump(b, 2, 0.36, IRISH_BPM);
const arc = j.flying ? s(PI * j.u) : 0, side = Math.floor(b / 2) % 2 === 0 ? 1 : -1, out = 0.55 * arc;
return irish({
legR: soften(L(side * out + 0.04, 0.05, 0.25 * arc, 0.6 + 0.4 * arc), j.crouch),
legL: soften(L(-side * out + 0.04, 0.05, 0.25 * arc, 0.6 + 0.4 * arc), j.crouch),
air: j.air, leanSide: -side * 0.1 * arc, tx: -side * 0.08 * arc
});
};
// ================= Act 7: drum & bass (174 BPM) =================
const T_DNB = 60 / 174;
const dnb = ({ lean = 0.08, ...o }) => P(Object.assign({ ik: 1 }, o, { leanFwd: lean, torsoX: lean * 0.5 }));
const FIST = (f, e) => A(0.35, f, -0.1, e);
const WIND = A(0.5, -0.6, 0, 0.3); // arms swung back, about to jump
const REACH = A(2.7, 0.2, 0, 0);
// Build-up: bouncing as the tempo climbs, hands go up through the roll, crouch… jump into the drop
function build(b) {
const rate = b < 12 ? 1 : b < 14 ? 2 : 4;
const dip = (1 + c(TAU * b * rate)) / 2;
const pre = smooth((b - 14.5) / 0.5);
let air = 0, fly = 0;
if (b >= 15) {
const u = b - 15, Hj = G * T_DNB * T_DNB / 8;
air = 4 * Hj * u * (1 - u); fly = s(PI * u);
}
const pump = A(2.5, 0.35, 0.15 * dip, 0.35 * dip);
const bothUp = smooth((b - 12) / 2);
const armR = blend(pump, REACH, bothUp);
const armL = blend(blend(FIST(0.9, 1.4), pump, smooth((b - 7.5) / 0.75)), REACH, bothUp);
const crouch = pre * (1 - fly);
return dnb({
hipH: 0.83 - 0.05 * dip * (1 - pre) - 0.28 * crouch, air,
lean: 0.08 + 0.3 * crouch, headNod: 0.2 * dip * (1 - pre) + 0.3 * pre - 0.3 * fly,
armR: blend(armR, WIND, crouch), armL: blend(armL, WIND, crouch)
});
}
// Drop: double-time skank, a hop on every 8th, kicks swapping from front to side every two bars
function skank(b) {
const j = jump(b, 0.5, 0.02, 174);
const kR = 0.5 + 0.5 * clamp(c(TAU * b) * 3, -1, 1), kL = 1 - kR;
const m16 = mod(b, 16);
const sideW = m16 < 12 ? smooth((m16 - 7.6) / 0.8) : 1 - smooth((m16 - 15.6) / 0.8);
const kick = (sd, k) => blend(
F(sd * lerp(0.12, 0.2, k), lerp(ANK, 0.12, k), lerp(0.02, 0.4, k), 0.5 * k),
F(sd * lerp(0.12, 0.46, k), lerp(ANK, 0.14, k), lerp(0.02, 0.08, k), 0.4 * k), sideW);
const land = Math.pow(clamp(1 - b / 0.6, 0, 1), 2);
const f = fract(b), nod = f < 0.06 ? smooth(f / 0.06) : Math.pow(1 - (f - 0.06) / 0.94, 3);
const catchArms = smooth(b / 0.35);
return dnb({
fR: kick(1, kR), fL: kick(-1, kL),
hipH: 0.78 - 0.05 * j.crouch - 0.3 * land, air: j.air,
lean: 0.16 + 0.2 * land, headNod: 0.28 * nod,
yaw: 0.22 * (kR - kL), headTurn: -0.12 * (kR - kL),
armR: blend(WIND, FIST(0.25 + 0.95 * kL, 1.5 - 0.7 * kL), catchArms),
armL: blend(WIND, FIST(0.25 + 0.95 * kR, 1.5 - 0.7 * kR), catchArms)
});
}
// Half-time: heavy stomps, headbang and fists slamming down on the snare
function halftime(b, n, gb) {
const f = fract(b / 2), k = s(PI * Math.pow(f, 0.75));
const right = Math.floor(b / 2) % 2 === 0;
const hit = Math.exp(-since(gb, 'snare').dt * 3);
const lift = (sd) => F(sd * 0.24, ANK + 0.26 * k, 0.03 + 0.09 * k, 0.2 * k);
const plant = (sd) => F(sd * 0.24, ANK, 0.03);
const fist = A(0.45, lerp(1.1, 0.35, hit), 0, lerp(1.4, 0.3, hit));
return dnb({
fR: right ? lift(1) : plant(1), fL: right ? plant(-1) : lift(-1),
hipH: 0.76 - 0.14 * hit, lean: 0.2 + 0.15 * hit, headNod: 0.1 + 0.55 * hit,
leanSide: (right ? -1 : 1) * 0.07 * k, kneeOut: 0.5, armR: fist, armL: fist
});
}
// ================= Act 8: whirling dervish, the sema (60 BPM: one beat a second) =================
// After the tape stop: bow with arms crossed, begin turning about the left foot and unfold the arms
// (right palm to the sky, left to the earth), whirl, slow down, fold the arms, bow.
const SEMA_BPM = 60, SEMA_LEN = 22;
const SKIRT_L = 0.8, FLARE_REST = 0.25; // skirt length (waist to hem) and its resting angle
const OMEGA_MAX = TAU * 11 / 13; // ≈ 0.85 turns a second: exactly 11 turns in all,
// so the closing bow faces the same way as the first
const smoothInt = (x) => x * x * x - x * x * x * x / 2; // ∫ smoothstep from 0 to x
// angular speed, angular acceleration and angle turned so far, `tl` seconds into the sema
function semaSpin(tl) {
const OM = OMEGA_MAX;
if (tl < 2.5) return { w: 0, a: 0, yaw: 0 };
if (tl < 7) { const x = (tl - 2.5) / 4.5; return { w: OM * smooth(x), a: OM * 6 * x * (1 - x) / 4.5, yaw: OM * 4.5 * smoothInt(x) }; }
const y1 = OM * 2.25;
if (tl < 16) return { w: OM, a: 0, yaw: y1 + OM * (tl - 7) };
const y2 = y1 + OM * 9;
if (tl < 19.5) { const x = (tl - 16) / 3.5; return { w: OM * (1 - smooth(x)), a: -OM * 6 * x * (1 - x) / 3.5, yaw: y2 + OM * 3.5 * (x - smoothInt(x)) }; }
return { w: 0, a: 0, yaw: y2 + OM * 1.75 };
}
const semaTime = (b) => clamp(b, 0, SEMA_LEN) * 60 / SEMA_BPM;
// arms: crossed on the chest → raised in front → open
const CROSSED = A(0.2, 0.6, -0.98, 3.1), RISE = A(0.35, 1.9, 0.1, 0.6);
const PALM_SKY = A(2.25, 0.25, 0.25, 0.1), PALM_EARTH = A(1.35, 0.2, -0.25, 0);
const semaArms = (u) => u < 0.5
? [blend(CROSSED, RISE, smooth(u / 0.5)), blend(CROSSED, RISE, smooth(u / 0.5))]
: [blend(RISE, PALM_SKY, smooth((u - 0.5) / 0.5)), blend(RISE, PALM_EARTH, smooth((u - 0.5) / 0.5))];
const PIVOT = [-0.07, 0, 0.02]; // the left foot: he turns about it
const sema = (b) => {
const tl = semaTime(b), spin = semaSpin(tl), spinU = spin.w / OMEGA_MAX;
const armU = tl < 2.5 ? 0 : tl < 6 ? smooth((tl - 2.5) / 3.5) : tl < 16.5 ? 1 : tl < 19.5 ? 1 - smooth((tl - 16.5) / 3) : 0;
const [armR, armL] = semaArms(armU);
const bow = Math.max(smooth(1 - Math.abs(tl - 1.1) / 0.9), smooth(1 - Math.abs(tl - 20.8) / 0.9));
// the right foot pushes off once per turn
const f = mod(spin.yaw / TAU, 1), push = spinU > 0.2 && f > 0.7 ? s(PI * (f - 0.7) / 0.3) : 0;
const r = rotY(PIVOT, spin.yaw);
const p = P({
ik: 1, hipH: 0.84, kneeOut: 0.2,
fL: F(PIVOT[0], ANK, PIVOT[2]), fR: F(0.14, ANK + 0.1 * push, 0.06 - 0.04 * push),
armR, armL, leanFwd: 0.35 * bow, torsoX: 0.35 * bow, headNod: 0.35 * bow, headTilt: 0.35 * armU,
yaw: spin.yaw, tx: PIVOT[0] - r[0], tz: PIVOT[2] - r[2]
});
// settle out of the frozen tape-stop pose into the opening bow
return b < 1.5 ? blend(halftime(15.999, 16, SEMA - 0.001), p, smooth(b / 1.5)) : p;
};
// The skirt is a cone whose angle comes from the spin (a conical pendulum, cos θ = g / ω²L).
// The cloth answers with a little lag, so it's integrated frame by frame.
const cloth = { flare: FLARE_REST, lag: 0 };
function updateCloth(gb, dt) {
let w = 0, a = 0;
if (gb >= SEMA) ({ w, a } = semaSpin(semaTime(gb - SEMA)));
const eq = w * w * SKIRT_L > G ? Math.acos(G / (w * w * SKIRT_L)) : 0;
const dts = dt * (reduced ? 1 / 3 : 1);
cloth.flare += (Math.max(FLARE_REST, eq) - cloth.flare) * (1 - Math.exp(-dts / 0.45));
cloth.lag += (0.3 * (w / OMEGA_MAX) + 0.08 * a - cloth.lag) * (1 - Math.exp(-dts / 0.3));
}
// ================= Running order =================
// `fade` is how many beats a move spends melting into the next one.
let DEAD = 0;
const shutdown = () => sema(SEMA_LEN - 0.001); // the ceremony is over: hold the last frame
const MOVES = [
{ fn: robotArms, len: 8, fade: 0.2, ease: 'snap', sec: 'robot' },
{ fn: armWave, len: 8, fade: 0.2, ease: 'snap', sec: 'robot' },
{ fn: tickWalk, len: 8, fade: 0.5, sec: 'robot' }, // rises onto his toes…
...MOON_ROUTE, // …and spins out into the moonwalk
{ fn: powerDown, len: 8, fade: 1.5, sec: 'power' }, // reboots as a b-boy
{ fn: toprock, len: 8, fade: 1.5, sec: 'break' },
{ fn: windmill, len: 8, fade: 1.5, sec: 'break' },
{ fn: freeze(-1), len: 4, fade: 1.5, sec: 'break' },
{ fn: toprock, len: 4, fade: 1.5, sec: 'break' },
{ fn: headspin, len: 8, fade: 1.5, sec: 'break' },
{ fn: freeze(1), len: 4, fade: 2, sec: 'break' }, // pops up into a disco point
{ fn: discoPoint, len: 8, fade: 1, sec: 'disco' },
{ fn: handsUp, len: 8, fade: 1, sec: 'disco' },
{ fn: discoKicks, len: 8, fade: 1, sec: 'disco' },
{ fn: discoSpin, len: 8, fade: 1, sec: 'disco' }, // spins to a stop…
{ fn: stamp, len: 8, fade: 0.75, sec: 'cossack' }, // …and stamps into the hopak
{ fn: prisiadka, len: 8, fade: 0.75, sec: 'cossack' },
{ fn: polzunets, len: 4, fade: 0.75, sec: 'cossack' },
{ fn: straddle, len: 8, fade: 0.4, sec: 'cossack' }, // lands into Irish dance
{ fn: sevens, len: 8, fade: 0.28, sec: 'irish' }, // each fade starts once he's landed
{ fn: kicks, len: 8, fade: 0.28, sec: 'irish' },
{ fn: leaps, len: 8, fade: 0.4, sec: 'irish' },
{ fn: clicks, len: 8, fade: 0.5, sec: 'irish' }, // lands into the build
{ fn: build, len: 16, fade: 0, sec: 'build' }, // lands straight on the drop
{ fn: skank, len: 32, fade: 0.5, sec: 'drop' },
{ fn: halftime, len: 16, fade: 0, sec: 'half' }, // tape stop on the last beat…
{ fn: sema, len: 22, fade: 0, sec: 'sema' }, // …a whirling dervish winds the set down…
{ fn: shutdown, len: 1, fade: 0, sec: 'dead' } // …screen off, back to the robot
];
const TOTAL = MOVES.reduce((n, m) => n + m.len, 0); // 273 beats ≈ 148 s
const SECTIONS = [];
MOVES.reduce((at, m) => {
if (!SECTIONS.length || SECTIONS[SECTIONS.length - 1][0] !== m.sec) SECTIONS.push([m.sec, at]);
return at + m.len;
}, 0);
const startOf = (name) => SECTIONS.find(([n]) => n === name)[1];
const MOON = startOf('moon'), BREAK = startOf('break'), DISCO = startOf('disco'), IRISH = startOf('irish');
const COSSACK = startOf('cossack');
const BUILD = startOf('build'), DROP_AT = startOf('drop');
const REBOOT = startOf('power') + 6, HAT_LAND = MOON + 1;
DEAD = startOf('dead');
const SEMA = startOf('sema');
// [from beat, to beat, bpm at start, bpm at end]
const TEMPO = [
[0, REBOOT, 100, 100], // robot and moonwalk share 100 BPM
[REBOOT, BREAK, 100, 108], // the reboot picks the tempo up
[BREAK, DISCO - 2, 108, 108],
[DISCO - 2, DISCO, 108, 116], // …and again as he gets up out of the freeze
[DISCO, IRISH - 1, 116, 116], // disco and the hopak share 116 BPM
[IRISH - 1, IRISH, 116, 118], // a nudge as he lands into the jig
[IRISH, BUILD, 118, 118],
[BUILD, BUILD + 12, 118, 174], // the build accelerates to 174
[BUILD + 12, SEMA - 1, 174, 174],
[SEMA - 1, SEMA, 174, 14], // tape stop
[SEMA, DEAD, SEMA_BPM, SEMA_BPM], // the sema keeps its own slow time
[DEAD, TOTAL, 70, 70]
];
const SCHEDULED = new Map([ // glitches that always happen, by 16th step
[REBOOT * 4, { dur: 0.22, strength: 1 }],
[DROP_AT * 4, { dur: 0.22, strength: 1 }],
[HAT_LAND * 4, { dur: 0.1, strength: 0.55 }],
[COSSACK * 4, { dur: 0.12, strength: 0.6 }], // the cap becomes a fur hat…
[IRISH * 4, { dur: 0.12, strength: 0.6 }], // …and back again
[SEMA * 4, { dur: 0.2, strength: 0.8 }] // the cap becomes a dervish's hat and skirt
]);
const GLITCH_ODDS = { robot: 0.3, moon: 0.15, power: 0, break: 0.3, disco: 0.3, cossack: 0.08, irish: 0.3, build: 0, drop: 0.3, half: 0.35, sema: 0.3, dead: 0 };
function tempoAt(gb) {
for (const [a, z, t0, t1] of TEMPO) if (gb < z) return lerp(t0, t1, clamp((gb - a) / (z - a), 0, 1));
return TEMPO[TEMPO.length - 1][3];
}
function sectionAt(gb) {
let i = SECTIONS.length - 1;
while (i > 0 && gb < SECTIONS[i][1]) i--;
return { sec: SECTIONS[i][0], b: gb - SECTIONS[i][1] };
}
// ---------- One drum machine for the whole set ----------
function drums(step) {
const { sec, b } = sectionAt(mod(step / 4, TOTAL));
const ls = Math.round(b * 4), st = mod(ls, 16), bar = Math.floor(ls / 16);
const hit = { kick: false, snare: false, hat: false };
if (sec === 'robot') { // mechanical four-to-the-floor
hit.kick = st % 4 === 0; hit.snare = st === 4 || st === 12; hit.hat = st % 2 === 0;
} else if (sec === 'moon') { // a tight groove; every toe stand lands on a hit
const toe = TOE_STEPS.includes(ls);
hit.kick = st === 0 || st === 8 || st === 10 || toe;
hit.snare = st === 4 || st === 12 || toe;
hit.hat = st % 2 === 0 || st === 15;
} else if (sec === 'power') {
if (bar === 0) hit.kick = st === 0 || st === 8; // power draining
else hit.kick = hit.snare = st === 8; // silence… then the reboot
} else if (sec === 'break') { // breakbeat
if (bar < 8) { hit.kick = st === 0 || st === 2 || st === 10; hit.snare = st === 4 || st === 12; hit.hat = st % 2 === 0; }
else { hit.kick = st === 0; hit.snare = st === 4 || st >= 12; hit.hat = st % 2 === 0 && st < 12; } // fill
} else if (sec === 'disco') { // four-to-the-floor with open hats on the off-beat
hit.kick = st % 4 === 0;
hit.snare = st === 4 || st === 12;
hit.hat = st % 4 === 2;
} else if (sec === 'sema') { // a slow frame-drum pulse, silent for the closing bow
if (ls < 80) { hit.kick = st === 0 || st === 8; hit.snare = st === 12; }
} else if (sec === 'cossack') { // oom-pah: a stamp on the beat, a snap on the off-beat
hit.kick = st % 4 === 0;
hit.snare = st % 4 === 2;
hit.hat = st % 4 === 3;
} else if (sec === 'irish') { // a reel: a stamp on every beat, 16th pickups
hit.kick = st % 4 === 0;
hit.snare = st === 4 || st === 12; // lands on the top of every leap and heel click
hit.hat = st % 2 === 0 || st === 3 || st === 11;
} else if (sec === 'build') {
if (bar < 3) { hit.kick = st === 0; hit.snare = bar === 2 && (st === 4 || st === 12); hit.hat = st % 2 === 0; }
else if (st < 8) hit.snare = st % 2 === 0; // snare roll into the drop, then a gap
else if (st < 12) hit.snare = true;
} else if (sec === 'drop') { // the same breakbeat, now at 174
hit.kick = st === 0 || st === 10;
hit.snare = st === 4 || st === 12;
hit.hat = st % 2 === 0 || st === 7 || st === 15;
} else if (sec === 'half') {
hit.kick = st === 0 || st === 11;
hit.snare = st === 8;
hit.hat = st % 4 === 2;
}
return hit;
}
function since(gb, type) {
const step = Math.floor(gb * 4);
for (let k = 0; k < 16; k++) if (drums(step - k)[type]) return { dt: gb - (step - k) / 4, step: step - k };
return { dt: 99, step: 0 };
}
function poseAt(gb) {
let m = mod(gb, TOTAL), i = 0;
while (m >= MOVES[i].len) { m -= MOVES[i].len; i++; }
const e = MOVES[i];
const cur = e.fn(m, e.len, gb);
if (e.fade > 0 && m > e.len - e.fade) {
const n = MOVES[(i + 1) % MOVES.length];
const next = n.fn(0, n.len, gb - m + e.len);
for (const k of ['yaw', 'spin']) { // turn the short way round
cur[k] = wrap(cur[k]);
next[k] = cur[k] + wrap(next[k] - cur[k]);
}
const x = (m - (e.len - e.fade)) / e.fade;
return blend(cur, next, e.ease === 'snap' ? easeOutBack(x) : smooth(x));
}
return cur;
}
// What he wears: the robot's visor; a fedora that drops onto his head as he spins out of the
// tick walk; a cap the reboot glitch swaps in (backwards for breaking, turned round for the drum & bass)
function lookAt(gb) {
if (gb < REBOOT) {
const fall = clamp((gb - (HAT_LAND - 0.8)) / 0.8, 0, 1);
return {
visor: 1 - smooth((gb - HAT_LAND + 0.05) / 0.15),
fedora: gb >= HAT_LAND - 0.8 ? 1 : 0,
hatY: gb < HAT_LAND ? 1.8 * (1 - fall * fall) : 0.03 * s(PI * clamp((gb - HAT_LAND) / 0.25, 0, 1)),
hatSpin: 3 * TAU * Math.pow(1 - fall, 2),
papakha: 0, cap: 0, capTurn: PI
};
}
if (gb >= SEMA) return {
visor: 0, fedora: 0, hatY: 0, hatSpin: 0, papakha: 0, cap: 0, capTurn: 0,
sikke: 1, skirt: 1, palms: true, flare: FLARE_REST, lag: 0,
spinU: semaSpin(semaTime(gb - SEMA)).w / OMEGA_MAX
};
const fur = gb >= COSSACK && gb < IRISH ? 1 : 0;
return { visor: 0, fedora: 0, hatY: 0, hatSpin: 0, papakha: fur, cap: 1 - fur, capTurn: PI * (1 - smooth((gb - BUILD - 1) / 1.5)) };
}
const CAMS = {
robot: { pitch: 0.22, cy: -0.05, scale: 0.27, follow: 0.6, orbit: 0.42 },
moon: { pitch: 0.22, cy: 0.08, scale: 0.27, follow: 0.6, orbit: 0.42 },
break: { pitch: 0.3, cy: -0.05, scale: 0.27, follow: 0.6, orbit: 0.42 },
disco: { pitch: 0.2, cy: -0.05, scale: 0.27, follow: 0.6, orbit: 0.42 },
cossack: { pitch: 0.22, cy: 0.3, scale: 0.235, follow: 0, orbit: 0.42 }, // room for the straddle jumps; the slide stays visible
irish: { pitch: 0.22, cy: -0.05, scale: 0.27, follow: 0.6, orbit: 0.42 },
dnb: { pitch: 0.28, cy: 0.1, scale: 0.265, follow: 0.6, orbit: 0.42 },
sema: { pitch: 0.26, cy: 0, scale: 0.25, follow: 0, orbit: 0.15 }
};
const camFor = (sec) => (sec === 'robot' || sec === 'power' ? 'robot' : sec === 'dead' ? 'sema' : CAMS[sec] ? sec : 'dnb');
// CRT switch-off at the end of the set, switch-on when the robot boots
const LINE = 0.006;
const easeIn = (x) => x * x * x;
const easeOut = (x) => 1 - Math.pow(1 - x, 3);
function crtAt(gb) {
if (gb >= DEAD) {
const u = gb - DEAD;
if (reduced) return { alpha: 1 - smooth(u / 0.5) };
if (u < 0.28) { const e = easeIn(u / 0.28); return { sx: 1, sy: lerp(1, LINE, e), beam: Math.pow(e, 4), show: true }; }
if (u < 0.46) { const e = easeIn((u - 0.28) / 0.18); return { sx: lerp(1, 0.015, e), sy: LINE, beam: 1, show: true }; }
const e = clamp((u - 0.46) / 0.3, 0, 1);
return { sx: 0.015 * (1 - 0.5 * e), sy: LINE, beam: 1 - e, show: false };
}
if (gb < 0.6) {
const u = gb / 0.6;
if (reduced) return { alpha: smooth(u) };
if (u < 0.25) return { sx: lerp(0.015, 1, easeOut(u / 0.25)), sy: LINE, beam: 1, show: true };
const x = (u - 0.25) / 0.75;
return { sx: 1, sy: lerp(LINE, 1, easeOutBack(x)), beam: Math.pow(1 - x, 3), show: true };
}
return null;
}
// ---------- Figure ----------
function buildFigure(pose, look) {
const hipR = [0.08, 0, 0], hipL = [-0.08, 0, 0], w = pose.ik;
const leg = (hip, side, Lg, Ft) => {
let knee = add(hip, mul(limbDir(side, Lg.abd, Lg.fwd), THIGH));
let ank = add(knee, mul(limbDir(side, Lg.abd * 0.6, Lg.fwd - Lg.knee), SHIN));
if (w > 0.001) {
const target = mix3(ank, [Ft.x, Ft.y - pose.hipH, Ft.z], w);
const hint = mix3(norm(sub(knee, hip)), norm([side * pose.kneeOut, 0.15, 1]), w);
[knee, ank] = ik(hip, target, THIGH, SHIN, hint);
}
// foot frame: turned out, tipped toes-down by `point`, and (for angle-driven legs)
// pointed along the shin by `toe`, as in Irish dance
const point = Ft.point * w, curl = Ft.curl * w, pointe = Lg.toe * (1 - w), turn = side * pose.footTurn;
const tip = (v, a) => rotY(rotX(v, a), turn);
let fwd = tip([0, 0, 1], point), down = tip([0, -1, 0], point);
if (pointe > 0.001) {
const f = norm(mix3(fwd, norm(sub(ank, knee)), pointe));
down = turnLike(down, fwd, f); fwd = f;
}
const at = (z) => add(ank, add(mul(down, ANK), mul(fwd, z)));
const ball = at(BALL_Z), toeDir = curl > 0.001 ? tip([0, 0, 1], point - curl) : fwd;
return [hip, knee, ank, at(HEEL_Z), ball, add(ball, mul(toeDir, TOE_LEN))];
};
const lr = leg(hipR, 1, pose.legR, pose.fR), ll = leg(hipL, -1, pose.legL, pose.fL);
const up = rotZ(rotX([0, 1, 0], pose.leanFwd), -pose.leanSide);
const neck = mul(up, SPINE), shoulder = mul(up, SPINE - 0.06);
const headOrient = (v) => rotZ(rotX(rotY(v, pose.headTurn), pose.leanFwd + pose.headNod), -pose.leanSide - pose.headTilt);
const headC = add(neck, mul(headOrient([0, 1, 0]), NECK + HEAD_R));
const torso = (v) => rotZ(rotX(v, pose.torsoX), -pose.leanSide);
const arm = (side, Ar) => {
const elbow = add(shoulder, mul(torso(limbDir(side, Ar.abd, Ar.fwd)), UPPER_ARM));
return [shoulder, elbow, add(elbow, mul(torso(limbDir(side, Ar.abd + Ar.eAbd, Ar.fwd + Ar.eFwd)), FOREARM))];
};
const ar = arm(1, pose.armR), al = arm(-1, pose.armL);
// body orientation: spin about the spine, pitch, roll, then yaw
const body = (p) => rotY(rotZ(rotX(rotY(p, pose.spin), pose.pitch), pose.roll), pose.yaw);
// angle-driven poses rest their lowest point on the floor; IK poses stand at their hip height
let lowest = body(headC)[1] - HEAD_R;
for (const p of [...lr, ...ll, ...ar, ...al]) lowest = Math.min(lowest, body(p)[1]);
const dy = lerp(FLOOR_Y - lowest + 0.01, FLOOR_Y + pose.hipH, w) + pose.air;
const polys = [];
const place = (p) => { const q = body(p); return [q[0] + pose.tx, q[1] + dy, q[2] + pose.tz]; };
const line = (pts, lw = 2.4) => polys.push({ pts: pts.map(place), color: WHITE, w: lw });
line([[0, 0, 0], neck]);
line([hipR, [0, 0, 0], hipL]);
line([add(shoulder, torso([0.1, 0, 0])), shoulder, add(shoulder, torso([-0.1, 0, 0]))], 2.0);
line(ar); line(al);
line(lr.slice(0, 3)); line(ll.slice(0, 3));
for (const Lg of [lr, ll]) line([Lg[4], Lg[2], Lg[3], Lg[4], Lg[5]], 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 t = TAU * k / 18; pts.push(headPt([HEAD_R * c(t) * c(a), HEAD_R * s(t), HEAD_R * c(t) * s(a)])); }
polys.push({ pts, color: WHITE, w: 1.0, dim: true });
}
if (look.visor > 0.02) {
const pts = [];
for (let k = 0; k <= 10; k++) { const a = -0.8 + 1.6 * k / 10; pts.push(headPt([HEAD_R * 1.04 * s(a), 0.03, HEAD_R * 1.04 * c(a)])); }
polys.push({ pts, color: WHITE, w: 2.4, alpha: look.visor });
}
if (look.fedora > 0.02) {
// fedora: brim, crown and pinched top, tipped slightly forward
const hat = (v) => headPt(rotX(add(rotY(v, look.hatSpin), [0, HEAD_R * 0.62 + look.hatY, 0]), 0.18));
const ring = (r, y, pinch = 0) => {
const pts = [];
for (let k = 0; k <= 24; k++) { const a = TAU * k / 24; pts.push(hat([r * c(a), y - pinch * Math.pow(c(a), 2), r * 1.1 * s(a)])); }
return pts;
};
const al = look.fedora;
polys.push({ pts: ring(0.27, 0), color: WHITE, w: 1.5, alpha: al });
polys.push({ pts: ring(0.155, 0.01), color: WHITE, w: 1.1, dim: true, alpha: al });
polys.push({ pts: ring(0.15, 0.14, 0.03), color: WHITE, w: 1.3, alpha: al });
for (let k = 0; k < 8; k++) {
const a = TAU * k / 8;
polys.push({ pts: [hat([0.155 * c(a), 0.01, 0.17 * s(a)]), hat([0.15 * c(a), 0.14 - 0.03 * Math.pow(c(a), 2), 0.165 * s(a)])], color: WHITE, w: 1.0, dim: true, alpha: al });
}
polys.push({ pts: [hat([0, 0.14, 0.165]), hat([0, 0.1, 0]), hat([0, 0.14, -0.165])], color: WHITE, w: 1.0, dim: true, alpha: al });
}
if (look.papakha > 0.02) {
// tall fur hat (papakha): a slightly flared cylinder
const hatA = look.papakha, y0 = HEAD_R * 0.55, hh = 0.2;
const ring = (y, r) => {
const pts = [];
for (let k = 0; k <= 20; k++) { const a = TAU * k / 20; pts.push(headPt([r * c(a), y, r * s(a)])); }
return pts;
};
polys.push({ pts: ring(y0, 0.165), color: WHITE, w: 1.5, alpha: hatA });
polys.push({ pts: ring(y0 + hh * 0.5, 0.175), color: WHITE, w: 0.9, dim: true, alpha: hatA });
polys.push({ pts: ring(y0 + hh, 0.185), color: WHITE, w: 1.5, alpha: hatA });
for (let k = 0; k < 10; k++) {
const a = TAU * k / 10;
polys.push({ pts: [headPt([0.165 * c(a), y0, 0.165 * s(a)]), headPt([0.185 * c(a), y0 + hh, 0.185 * s(a)])], color: WHITE, w: 0.9, dim: true, alpha: hatA });
}
}
if (look.cap > 0.02) {
const capPt = (v) => headPt(rotY(v, look.capTurn));
const brim = [], peak = [];
for (let k = 0; k <= 18; k++) { const a = PI * k / 18; brim.push(capPt([HEAD_R * 1.06 * c(a), HEAD_R * 0.25, HEAD_R * 1.06 * s(a)])); }
for (let k = 0; k <= 10; k++) { const a = PI * k / 10; peak.push(capPt([HEAD_R * 0.95 * c(a), HEAD_R * 0.22, HEAD_R + 0.1 * s(a)])); }
polys.push({ pts: brim, color: WHITE, w: 1.4, alpha: look.cap });
polys.push({ pts: peak, color: WHITE, w: 1.4, alpha: look.cap });
for (const q of [0.35, 0.7]) {
const arc = [];
for (let k = 0; k <= 12; k++) { const a = PI * k / 12; arc.push(capPt([HEAD_R * 1.04 * c(a) * c(q), HEAD_R * (0.25 + 0.75 * s(q)), HEAD_R * 1.04 * s(a) * c(q)])); }
polys.push({ pts: arc, color: WHITE, w: 1.0, dim: true, alpha: look.cap });
}
}
if (look.sikke > 0.02) {
// sikke: tall, slightly tapering felt hat with a rounded top
const hy = HEAD_R * 0.5, hh = 0.34, ha = look.sikke;
const hRing = (y, r) => { const pts = []; for (let k = 0; k <= 20; k++) { const a = TAU * k / 20; pts.push(headPt([r * c(a), y, r * s(a)])); } return pts; };
polys.push({ pts: hRing(hy, 0.15), color: WHITE, w: 1.5, alpha: ha });
polys.push({ pts: hRing(hy + hh * 0.5, 0.135), color: WHITE, w: 0.9, dim: true, alpha: ha });
polys.push({ pts: hRing(hy + hh, 0.12), color: WHITE, w: 1.4, alpha: ha });
for (let k = 0; k < 8; k++) {
const a = TAU * k / 8;
polys.push({ pts: [headPt([0.15 * c(a), hy, 0.15 * s(a)]), headPt([0.12 * c(a), hy + hh, 0.12 * s(a)]), headPt([0.06 * c(a), hy + hh + 0.035, 0.06 * s(a)])], color: WHITE, w: 0.9, dim: true, alpha: ha });
}
}
if (look.skirt > 0.02) {
// tennure: a cone from the waist. At rest it hangs in deep pleats; as it flares the pleats
// pull out, a wave runs round the hem, and the hem trails the waist
const th = look.flare, spinU = look.spinU, NS = 48, ska = look.skirt, WAIST_R = 0.12;
const pleatW = (1 - smooth((th - FLARE_REST) / 0.5)) * 0.05;
const skirtPt = (a, frac) => {
const wave = 0.035 * spinU * s(a * 5 - pose.yaw * 2.3), aa = a - look.lag * frac;
const r = WAIST_R + frac * (SKIRT_L * s(th) + pleatW * s(a * 12) * frac);
const q = place([r * c(aa), 0.06 - frac * SKIRT_L * c(th) + wave * frac * frac, r * s(aa)]);
q[1] = Math.max(q[1], FLOOR_Y + 0.02);
return q;
};
const ringAt = (frac) => { const pts = []; for (let k = 0; k <= NS; k++) pts.push(skirtPt(TAU * k / NS, frac)); return pts; };
polys.push({ pts: ringAt(0), color: WHITE, w: 1.6, alpha: ska });
polys.push({ pts: ringAt(0.55), color: WHITE, w: 0.8, dim: true, alpha: ska });
const hem = ringAt(1);
polys.push({ pts: hem, color: WHITE, w: 1.8, alpha: ska });
for (let k = 0; k < 16; k++) {
const a = TAU * k / 16, pts = [];
for (let j = 0; j <= 6; j++) pts.push(skirtPt(a, j / 6));
polys.push({ pts, color: WHITE, w: 0.9, dim: true, alpha: ska });
}
// faint shadow of the hem on the floor
polys.push({ pts: hem.map((q) => [q[0], FLOOR_Y + 0.003, q[2]]), color: FLOOR, w: 1.0, alpha: 0.5 * spinU * ska });
}
// hands: small circles, or open palms in the sema (right facing up, left facing down)
for (const [Ar, up] of [[ar, 1], [al, -1]]) {
const h = Ar[2], pts = [];
for (let k = 0; k <= 12; k++) {
const a = TAU * k / 12;
pts.push(place(add(h, look.palms ? [0.05 * c(a), 0.004 * up, 0.035 * s(a)] : [0.035 * c(a), 0.035 * s(a), 0])));
}
polys.push({ pts, color: WHITE, w: 1.2 });
}
// where the feet are, for the light-up tiles and skid marks
const feet = [[lr, pose.slideR], [ll, pose.slideL]].map(([Lg, slide]) => {
const heel = place(Lg[3]), ball = place(Lg[4]), toe = place(Lg[5]), low = toe[1] < ball[1] ? toe : ball;
const lowY = Math.min(heel[1], ball[1], toe[1]);
return { p: low, mid: place(mix3(Lg[3], Lg[4], 0.5)), down: lowY < FLOOR_Y + 0.02, slide };
});
return { polys, feet };
}
// ---------- Dance floor: lit by the drums, by his feet in the moonwalk, hopak and Irish dance,
// and as a flashing checkerboard for the disco ----------
const hash = (n) => { let x = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b); x ^= x >>> 13; x = Math.imul(x, 0xc2b2ae35); x ^= x >>> 16; return (x >>> 0) / 4294967296; };
const tileOf = (p) => {
const i = Math.floor((p[0] + HALF_W) / TILE), j = Math.floor((p[2] + HALF_W) / TILE);
return i >= 0 && i < N && j >= 0 && j < N ? i * N + j : -1;
};
// 'trace': tiles stay lit while a foot is on them; 'stamp': a tile flashes when a foot lands on it
const FOOT_LIGHT = { moon: 'trace', irish: 'trace', cossack: 'stamp' };
const footGlow = new Float32Array(N * N), wasDown = [false, false];
function lightFootsteps(feet, dt, mode) {
const k = Math.exp(-dt * 1.4);
for (let i = 0; i < footGlow.length; i++) footGlow[i] *= k;
feet.forEach((f, n) => {
const lit = mode === 'trace' ? f.down : mode === 'stamp' && f.down && !wasDown[n];
const tile = lit ? tileOf(f.p) : -1;
if (tile >= 0) footGlow[tile] = 1;
wasDown[n] = f.down;
});
}
// Skid marks left by the sliding feet
const trails = [[], []];
const TRAIL_LIFE = 1.4;
function recordTrails(feet, t) {
feet.forEach((f, n) => {
const tr = trails[n];
if (f.slide > 0.5 && f.down) tr.push({ p: [f.mid[0], FLOOR_Y + 0.002, f.mid[2]], t });
else if (tr.length && tr[tr.length - 1] !== null) tr.push(null);
while (tr.length && (tr[0] === null || t - tr[0].t > TRAIL_LIFE)) tr.shift();
});
}
function buildTrails(t) {
const polys = [];
for (const tr of trails) {
for (let i = 1; i < tr.length; i++) {
const a = tr[i - 1], b = tr[i];
if (!a || !b || Math.hypot(a.p[0] - b.p[0], a.p[2] - b.p[2]) > 0.1) continue;
const life = 1 - (t - b.t) / TRAIL_LIFE;
polys.push({ pts: [a.p, b.p], color: WHITE, w: 2.2, alpha: 0.6 * life * life });
}
}
return polys;
}
function buildFloor(gb, sec, b, power, center, hem = { r: 0, glow: 0 }) {
const K = since(gb, 'kick'), S = since(gb, 'snare'), Hh = since(gb, 'hat');
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 riser = sec === 'build' && b >= 12 ? smooth((b - 12) / 3) * 0.35 : 0;
const bass = sec === 'drop' ? 0.5 : sec === 'half' ? 2 : 0;
const gain = { moon: 0.45, irish: 0.6, cossack: 0.6, disco: 0.55, sema: 0.35 }[sec] ?? 1;
const checker = mod(Math.floor(K.step / 4), 2), checkGlow = sec === 'disco' ? 0.55 * Math.exp(-K.dt * 1.8) : 0;
const hatTile = Math.floor(hash(Hh.step) * N * N);
const rowMode = Math.floor(hash(S.step + 7) * 2), rowPick = Math.floor(hash(S.step + 3) * N);
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;
const d = Math.hypot(cx - center[0], cz - center[1]);
let v = 0;
// kick: a ring of light bursting out from wherever he is
v += Math.exp(-K.dt * 2.5) * Math.exp(-Math.pow(d - K.dt * 2.2, 2) / 0.03);
// snare: a whole row or column flashes
if ((rowMode ? i : j) === rowPick) v += Math.exp(-S.dt * 4);
// hi-hat: one tile flickers
if (i * N + j === hatTile) v += 0.7 * Math.exp(-Hh.dt * 9);
// bass: a slow wave rolling across the floor
if (bass) v += 0.22 * (0.5 + 0.5 * s(cx * 3.2 - cz * 1.4 - gb * PI * bass));
v += riser * (0.5 + 0.5 * s(gb * TAU * 4 + d * 6));
v *= gain;
// disco: the classic light-up floor, a checkerboard that flips on every kick
if (checkGlow && mod(i + j, 2) === checker) v += checkGlow;
// footsteps: tiles light up under his feet
v += 0.85 * footGlow[i * N + j];
// sema: a ring of tiles under the hem, widening as the skirt flares
if (hem.glow) v += hem.glow * Math.exp(-Math.pow(d - hem.r, 2) / 0.012);
if (sec === 'robot' || sec === 'power') v = Math.round(v * 4) / 4; // the robot's floor works in steps
v = clamp(v, 0, 1) * power;
if (v < 0.06) continue;
for (const [inset, th] of [[0.025, 0], [0.065, 0.45], [0.1, 0.75]]) {
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.3, alpha: v });
}
}
return polys;
}
// ---------- Projection ----------
function project(p, view, jitter) {
let [x, y, z] = p;
x -= view.fx; y -= view.cy; z -= view.fz;
if (jitter) { x += (Math.random()-.5)*jitter; y += (Math.random()-.5)*jitter; }
const cy = c(view.yaw), sy = s(view.yaw);
const x1 = x * cy + z * sy, z1 = -x * sy + z * cy;
const cx = c(view.pitch), sx = s(view.pitch);
const y1 = y * cx - z1 * sx, z2 = y * sx + z1 * cx;
const d = 5, persp = d / (d - z2);
const scale = W * view.scale;
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 song = 0, prevStep = -1; // song position in beats, advanced by the tempo map
let glitchT = 0, glitchStrength = 0, beatShift = 0;
let orbit = 0, last = performance.now();
let ringAnim = null, ringRate = 1;
const cam = Object.assign({}, CAMS.robot);
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;
song += dt * tempoAt(mod(song, TOTAL)) / 60 * (reduced ? 1 / 3 : 1);
const gb = mod(song, TOTAL);
const tempo = tempoAt(gb);
const { sec } = sectionAt(gb);
// Glitches land on the music: the reboot, the hat landing and the drop always glitch, snares sometimes do
const step = Math.floor(song * 4);
if (!reduced) {
for (let st = prevStep + 1; st <= step; st++) {
const ws = mod(st, TOTAL * 4), fixed = SCHEDULED.get(ws);
if (fixed) {
glitchT = fixed.dur; glitchStrength = fixed.strength; beatShift = 0;
} else if (glitchT <= 0 && drums(st).snare && Math.random() < GLITCH_ODDS[sectionAt(ws / 4).sec]) {
glitchT = 0.05 + Math.random() * 0.12;
glitchStrength = 0.4 + Math.random() * 0.5;
beatShift = Math.random() < 0.4 ? -0.25 : 0; // stutter: repeat the last 16th
}
}
}
prevStep = step;
const g = glitchT > 0;
if (g) glitchT -= dt; else beatShift = 0;
const shown = mod(song + beatShift, TOTAL);
const at = sectionAt(shown);
// the whole rig loses power through the tape stop, comes back slowly for the sema, and is off while dark
const power = gb >= DEAD ? 0 : gb >= SEMA ? lerp(14 / 174, 1, smooth((gb - SEMA) / 3))
: gb >= SEMA - 1 ? tempo / 174 : 1;
orbit += dt * cam.orbit * (reduced ? 0.36 : 1) * power;
// camera eases between acts, and resets while the screen is dark
if (gb >= DEAD + 0.75) Object.assign(cam, CAMS.robot);
else {
const tgt = CAMS[camFor(sec)], k = 1 - Math.exp(-dt * 2.5);
for (const key in cam) cam[key] += (tgt[key] - cam[key]) * k;
}
// the yellow ring thumps on the kick and spins with the tempo
const kEnv = power * Math.exp(-since(shown, 'kick').dt * 5);
if (ringEl) ringEl.style.transform = `rotate(-90deg) scale(${1 + 0.035 * kEnv})`;
if (!ringAnim && arcEl && arcEl.getAnimations) ringAnim = arcEl.getAnimations()[0] || null;
const rate = Math.max(0.15, (tempo / 108) * (gb >= DEAD ? 0 : 1));
if (ringAnim && Math.abs(rate - ringRate) > 0.02) { ringAnim.playbackRate = rate; ringRate = rate; }
const t = now / 1000;
const pose = poseAt(shown);
updateCloth(gb, dt);
const look = lookAt(shown);
if (look.skirt) { look.flare = cloth.flare; look.lag = cloth.lag; }
const fig = buildFigure(pose, look);
lightFootsteps(fig.feet, dt, FOOT_LIGHT[at.sec]);
recordTrails(fig.feet, t);
const view = {
yaw: orbit, pitch: cam.pitch + s(t * 0.7) * 0.04, cy: cam.cy, scale: cam.scale,
fx: pose.tx * cam.follow, fz: pose.tz * cam.follow
};
const hem = { r: 0.12 + SKIRT_L * s(cloth.flare), glow: look.skirt ? 0.7 * look.spinU : 0 };
const polys = buildFloor(shown, at.sec, at.b, power, [pose.tx, pose.tz], hem).concat(buildTrails(t), fig.polys);
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, 0, 0.03 * glitchStrength);
strokeAll(polys, view, GHOST_B, 0.6, off, (Math.random()-.5)*2*dpr, 0.03 * glitchStrength);
}
strokeAll(polys, view, null, g ? 0.85 : 1, 0, 0, g ? 0.02 : (reduced ? 0 : 0.004));
bctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, W, H);
const crt = crtAt(gb);
ctx.save();
if (crt && reduced) ctx.globalAlpha = crt.alpha;
else if (crt) {
ctx.translate(W / 2, H / 2);
ctx.scale(Math.max(crt.sx, 1e-3), Math.max(crt.sy, 1e-3));
ctx.translate(-W / 2, -H / 2);
}
if (!crt || reduced || crt.show) {
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);
}
} else {
ctx.drawImage(buf, 0, 0);
}
}
ctx.restore();
// the electron beam: a bright line (then a dot) as the picture collapses or opens out
if (crt && !reduced && crt.beam > 0.01) {
const bw = Math.max(2 * dpr, W * crt.sx), bh = clamp(H * crt.sy, 1.5 * dpr, 3 * dpr);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = crt.beam * 0.9;
ctx.fillStyle = WHITE;
ctx.shadowColor = WHITE; ctx.shadowBlur = 10 * dpr;
ctx.fillRect(W / 2 - bw / 2, H / 2 - bh / 2, bw, bh);
ctx.restore();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>