// Frame motion. Everything here writes to #frameMover's transform and nothing // else, so additional animation layers can be nested inside without conflict. // // An animation is `start(context) => stop()`. `context` carries the element to // move and a capture handle (possibly null). `stop()` must undo everything the // animation set up — timers, listeners, DOM — because the next animation takes // over the same element immediately. import { FRAME_SCALE, BOUNCE_SPEED, CORNER_INTERVAL_SECONDS, CORNER_MARGIN, DRAG_FRAME_SCALE, DRAG_MIN_SCALE, DRAG_MAX_SCALE, TRAIL_SPEED, TRAIL_TURN_RATE, TRAIL_SPACING, TRAIL_LENGTH, TRAIL_FADE_SECONDS, WASH_CYCLE_SECONDS, WASH_FRAME_SCALE, WASH_SPREAD, WASH_STRENGTH, PONG_FRAME_SCALE, PONG_BALL_SPEED, PONG_PADDLE_SPEED, PONG_BALL_SIZE, PONG_PADDLE_MARGIN, PONG_MAX_ANGLE, PONG_MISS_CHANCE, PONG_SERVE_SECONDS, AUTO_ANIMATION, } from '/config.js'; const ASPECT = 9 / 16; /** * Frame size for a viewport: `scale` of the width, but never taller than * `scale` of the height, so a wide screen does not get a letterbox strip. */ export function frameSize(viewportWidth, viewportHeight, scale = FRAME_SCALE) { let width = viewportWidth * scale; let height = width * ASPECT; const maxHeight = viewportHeight * scale; if (height > maxHeight) { height = maxHeight; width = height / ASPECT; } return { width, height }; } /** Size the frame to the current viewport and report the size back. */ function applyFrameSize(element, scale = FRAME_SCALE) { const size = frameSize(window.innerWidth, window.innerHeight, scale); element.style.width = `${size.width}px`; element.style.height = `${size.height}px`; return size; } function place(element, x, y) { element.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`; } // --- picker ------------------------------------------------------------- /** * Shuffle bag over 0..count-1: every animation comes up exactly once per pass, * so selection stays even, and a swap at the bag boundary keeps the same one * from landing twice in a row. Pure apart from the injected RNG. */ export function createPicker(count, random = Math.random) { let bag = []; let last = -1; function refill() { bag = Array.from({ length: count }, (_, i) => i); // Fisher–Yates. for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; } // next() pops off the end, so bag[length-1] is served first. A repeat can // only happen right here, at the seam between two bags. if (bag.length > 1 && bag[bag.length - 1] === last) { const j = Math.floor(random() * (bag.length - 1)); [bag[bag.length - 1], bag[j]] = [bag[j], bag[bag.length - 1]]; } } return { next() { if (count <= 1) return 0; if (bag.length === 0) refill(); last = bag.pop(); return last; }, }; } // --- bounce ------------------------------------------------------------- /** * Diagonal drift with reflection off the viewport edges. Pure state + step, so * the physics can be exercised without a browser and without waiting on rAF. */ export function createBouncer({ x = 0, y = 0 } = {}) { const bouncer = { x, y, // A shallow diagonal reads better than 45°, and never retraces its path. vx: BOUNCE_SPEED, vy: BOUNCE_SPEED * 0.62, step(dt, bounds) { const maxX = Math.max(0, bounds.width); const maxY = Math.max(0, bounds.height); bouncer.x += bouncer.vx * dt; bouncer.y += bouncer.vy * dt; // Reflect, mirroring the overshoot back inside the viewport. if (bouncer.x <= 0) { bouncer.x = Math.min(-bouncer.x, maxX); bouncer.vx = Math.abs(bouncer.vx); } else if (bouncer.x >= maxX) { bouncer.x = Math.max(maxX - (bouncer.x - maxX), 0); bouncer.vx = -Math.abs(bouncer.vx); } if (bouncer.y <= 0) { bouncer.y = Math.min(-bouncer.y, maxY); bouncer.vy = Math.abs(bouncer.vy); } else if (bouncer.y >= maxY) { bouncer.y = Math.max(maxY - (bouncer.y - maxY), 0); bouncer.vy = -Math.abs(bouncer.vy); } return bouncer; }, }; return bouncer; } export function startBounce({ element }) { let size = applyFrameSize(element); const bouncer = createBouncer({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2, }); function bounds() { return { width: window.innerWidth - size.width, height: window.innerHeight - size.height, }; } function measure() { size = applyFrameSize(element); const limit = bounds(); bouncer.x = Math.min(bouncer.x, Math.max(0, limit.width)); bouncer.y = Math.min(bouncer.y, Math.max(0, limit.height)); } window.addEventListener('resize', measure); let last = performance.now(); let handle = 0; let stopped = false; function frame(now) { if (stopped) return; // Clamped so a stalled tab does not teleport the frame on resume. const dt = Math.min((now - last) / 1000, 0.05); last = now; bouncer.step(dt, bounds()); place(element, bouncer.x, bouncer.y); handle = requestAnimationFrame(frame); } handle = requestAnimationFrame(frame); return function stop() { // The flag matters as much as the cancel: a frame already queued would // otherwise fire once more and fight whatever animation comes next. stopped = true; cancelAnimationFrame(handle); window.removeEventListener('resize', measure); }; } // --- corners ------------------------------------------------------------ // Clockwise from the top left. const CORNERS = 4; /** Top-left offset for a corner, inset by `margin` from the viewport edge. */ export function cornerPosition(index, size, viewport, margin) { const right = index === 1 || index === 2; const bottom = index === 2 || index === 3; return { x: right ? Math.max(margin, viewport.width - size.width - margin) : margin, y: bottom ? Math.max(margin, viewport.height - size.height - margin) : margin, }; } // How many stills each corner holds, and how far apart they sit as a fraction // of the frame size. Deep enough that a corner reads as a pile, shallow enough // that the stack stays in its own quarter of the screen. const CORNER_STACK = 3; const CORNER_STACK_STEP = 0.09; /** * Top-left offset for the `depth`-th still of a corner: the same spot as the * corner itself, nudged inward (toward the middle of the screen) once per * depth. Successive stills therefore never land on top of each other — each * one leaves the edges of the ones below it showing. */ export function cornerStackPosition(index, depth, size, viewport, margin) { const at = cornerPosition(index, size, viewport, margin); const right = index === 1 || index === 2; const bottom = index === 2 || index === 3; const step = depth * CORNER_STACK_STEP; return { x: at.x + (right ? -1 : 1) * step * size.width, y: at.y + (bottom ? -1 : 1) * step * size.height, }; } /** * The video parks in one random corner and stays there; every few seconds a * screenshot of it lands in the next of the three *other* corners, cycling * clockwise. Because the video's own corner is never a slot, a still can never * cover the video — and since the crop only ever covers the video's rect, a * still never contains another still either. * * Each corner stacks CORNER_STACK stills at staggered offsets, and the newest * shot always moves to the top of the layer, so a full corner still reads as * something new arriving rather than as an unchanged picture. */ export function startCorners(context) { const { element } = context; const layer = document.getElementById('stills'); const home = Math.floor(Math.random() * CORNERS); // The three non-home corners, in clockwise order starting after the video. const slots = [1, 2, 3].map((step) => (home + step) % CORNERS); // Slot-major: consecutive shots move to the next corner, and the depth only // advances once every corner at the current depth has been filled. const cells = []; for (let depth = 0; depth < CORNER_STACK; depth += 1) { for (const corner of slots) { const img = document.createElement('img'); layer.appendChild(img); cells.push({ img, corner, depth }); } } let next = 0; let size; function layout() { size = applyFrameSize(element); const viewport = { width: window.innerWidth, height: window.innerHeight }; const margin = Math.min(viewport.width, viewport.height) * CORNER_MARGIN; const spot = cornerPosition(home, size, viewport, margin); place(element, spot.x, spot.y); for (const cell of cells) { const at = cornerStackPosition(cell.corner, cell.depth, size, viewport, margin); cell.img.style.width = `${size.width}px`; cell.img.style.height = `${size.height}px`; place(cell.img, at.x, at.y); } } function shoot() { // Read late: the capture handle may still be waiting on the operator to // answer the screen-share prompt when this animation starts. if (!context.capture) return; const rect = element.getBoundingClientRect(); const shot = context.capture.grab(rect); if (!shot) return; const cell = cells[next]; cell.img.src = shot; cell.img.classList.add('visible'); // Re-append so the freshest still paints over the rest of its pile. layer.appendChild(cell.img); next = (next + 1) % cells.length; } layout(); window.addEventListener('resize', layout); const timer = setInterval(shoot, CORNER_INTERVAL_SECONDS * 1000); return function stop() { clearInterval(timer); window.removeEventListener('resize', layout); for (const cell of cells) cell.img.remove(); }; } // --- trail -------------------------------------------------------------- // The frame glides along a curving path and drops a photograph of itself every // so often. Each still stays where it was taken and fades, so the live video // reads as the head of a comet whose tail is its own recent past. /** * Steering toward a waypoint at constant speed, with a cap on how fast the * heading may change. The cap is the whole point: an instant turn would lay the * stills out in straight dotted lines, and it is the curve that makes a tail * read as one. Pure apart from the injected RNG, so the path can be exercised * without a browser. */ export function createGlider({ x = 0, y = 0 } = {}, random = Math.random) { // How close counts as arrived. Anything tighter than the turn radius // (speed / turn rate) risks the glider circling a waypoint forever, unable to // steer hard enough to land on it. const reach = TRAIL_SPEED / TRAIL_TURN_RATE; function normalise(angle) { return Math.atan2(Math.sin(angle), Math.cos(angle)); } const glider = { x, y, heading: random() * Math.PI * 2, target: null, retarget(bounds) { glider.target = { x: random() * Math.max(0, bounds.width), y: random() * Math.max(0, bounds.height), }; return glider.target; }, step(dt, bounds) { const maxX = Math.max(0, bounds.width); const maxY = Math.max(0, bounds.height); if (!glider.target) glider.retarget(bounds); if (Math.hypot(glider.target.x - glider.x, glider.target.y - glider.y) < reach) { glider.retarget(bounds); } // Shortest way round to the bearing of the waypoint, then take at most // this step's share of the turn rate. const bearing = Math.atan2(glider.target.y - glider.y, glider.target.x - glider.x); const turn = normalise(bearing - glider.heading); glider.heading = normalise( glider.heading + clamp(turn, -TRAIL_TURN_RATE * dt, TRAIL_TURN_RATE * dt), ); glider.x += Math.cos(glider.heading) * TRAIL_SPEED * dt; glider.y += Math.sin(glider.heading) * TRAIL_SPEED * dt; // Reflect off the edges rather than clamping and sliding along them. The // waypoint is always inside the bounds, so steering picks up from there // and pulls the glider back inward instead of straight into the wall. if (glider.x < 0 || glider.x > maxX) { glider.x = clamp(glider.x, 0, maxX); glider.heading = normalise(Math.PI - glider.heading); } if (glider.y < 0 || glider.y > maxY) { glider.y = clamp(glider.y, 0, maxY); glider.heading = normalise(-glider.heading); } return glider; }, }; return glider; } export function startTrail(context) { const { element } = context; const layer = document.getElementById('stills'); let size = applyFrameSize(element); const glider = createGlider({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2, }); // A ring buffer: the tail is a fixed number of images, and the oldest one is // what the next drop reuses. `rect` doubles as the "currently showing" flag. const cells = []; for (let i = 0; i < TRAIL_LENGTH; i += 1) { const img = document.createElement('img'); img.classList.add('trail'); layer.appendChild(img); cells.push({ img, born: 0, rect: null }); } let next = 0; let travelled = 0; function bounds() { return { width: window.innerWidth - size.width, height: window.innerHeight - size.height, }; } function measure() { size = applyFrameSize(element); const limit = bounds(); glider.x = Math.min(glider.x, Math.max(0, limit.width)); glider.y = Math.min(glider.y, Math.max(0, limit.height)); } /** True when the frame overlaps nothing that is still showing in the tail. */ function clearOfTail(rect) { for (const cell of cells) { const other = cell.rect; if (!other) continue; if ( rect.left < other.right && rect.right > other.left && rect.top < other.bottom && rect.bottom > other.top ) { return false; } } return true; } /** Photograph the frame where it stands. Reports whether a still landed. */ function drop(now) { // Read late: the capture handle may still be waiting on the operator to // answer the screen-share prompt when this animation starts. if (!context.capture) return false; const rect = element.getBoundingClientRect(); // The capture is a crop of the tab, so an older still lying under the frame // would be photographed into the new one. Skip this drop instead; the // travelled distance is not spent, so it happens as soon as the frame is // back in the clear. if (!clearOfTail(rect)) return false; const shot = context.capture.grab(rect); if (!shot) return false; const cell = cells[next]; cell.img.src = shot; cell.img.style.width = `${rect.width}px`; cell.img.style.height = `${rect.height}px`; place(cell.img, rect.left, rect.top); cell.born = now; cell.rect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; // Re-append so the freshest still paints over the rest of the tail. layer.appendChild(cell.img); next = (next + 1) % cells.length; return true; } function fade(now) { for (const cell of cells) { if (!cell.rect) continue; const life = 1 - (now - cell.born) / 1000 / TRAIL_FADE_SECONDS; if (life <= 0) { cell.img.style.opacity = '0'; // Gone, so it stops holding up drops that pass over its old spot. cell.rect = null; continue; } // Squared: a still holds its brightness for a moment and then goes // quickly, which thins the tail out instead of trailing a long grey smear. cell.img.style.opacity = (life * life).toFixed(3); } } place(element, glider.x, glider.y); window.addEventListener('resize', measure); let last = performance.now(); let handle = 0; let stopped = false; function frame(now) { if (stopped) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; const wasX = glider.x; const wasY = glider.y; glider.step(dt, bounds()); place(element, glider.x, glider.y); // Spacing by distance rather than by a timer keeps the stills evenly spread // however the frame is turning. travelled += Math.hypot(glider.x - wasX, glider.y - wasY); if (travelled >= size.width * TRAIL_SPACING && drop(now)) travelled = 0; fade(now); handle = requestAnimationFrame(frame); } handle = requestAnimationFrame(frame); return function stop() { stopped = true; cancelAnimationFrame(handle); window.removeEventListener('resize', measure); for (const cell of cells) cell.img.remove(); }; } // --- drag and resize ---------------------------------------------------- // Someone shoving a video window around a desktop: a pointer travels to the // frame, grabs it, drags it somewhere else, lets go for a moment, then takes // hold of one corner and pulls the frame to a new size. The pointer is drawn, // so every move on screen has a visible cause. const ARROW = ``; // Where on the frame the pointer takes hold, as a fraction of its height — // the top strip, where a window's title bar would be. const GRAB_BAND = [0.06, 0.3]; // Seconds the pointer rests between grabs, and how far it drifts while resting. const PAUSE = [0.5, 1.7]; const IDLE_DRIFT = 2.5; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function between(min, max) { return min + Math.random() * (max - min); } /** Smoothstep: no jerk at either end, which is what an arm does. */ function smooth(t) { return t * t * (3 - 2 * t); } /** * Progress along a hand-driven move: a gentle launch, then a slight overshoot * that pulls back onto the target. `overshoot` 0 is a plain smooth ramp; the * bigger it gets, the further past the target the move swings first. */ export function settle(t, overshoot = 0.6) { const back = smooth(clamp(t, 0, 1)) - 1; return 1 + (overshoot + 1) * back ** 3 + overshoot * back ** 2; } /** * Pointer position at `t` (0..1) on its way from `from` to `to`. `curve` bows * the path sideways — a hand does not move in a straight line — and its sign * decides which way the arc leans. */ export function pointerAt(from, to, t, { curve = 0, overshoot = 0.6 } = {}) { const dx = to.x - from.x; const dy = to.y - from.y; const p = settle(t, overshoot); const bow = Math.sin(Math.PI * clamp(t, 0, 1)) * curve; return { x: from.x + dx * p - dy * bow, y: from.y + dy * p + dx * bow, }; } /** * The rect you get by dragging `corner` (0 = top left, clockwise) to `pointer` * while the opposite corner stays pinned, the way a window resize works. * * The aspect ratio is locked — a squashed video reads as broken rather than as * someone resizing — so the axis the pointer pulled furthest wins, and the * width is clamped into `min`..`max`. */ export function resizeRect(rect, corner, pointer, { min, max }) { const anchor = anchorOf(rect, corner); const pulled = Math.max( Math.abs(pointer.x - anchor.x), Math.abs(pointer.y - anchor.y) / ASPECT, ); return rectFromWidth(rect, corner, clamp(pulled, min, max)); } /** The corner that stays put while `corner` is dragged. */ function anchorOf(rect, corner) { const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; return { x: right ? rect.x : rect.x + rect.width, y: bottom ? rect.y : rect.y + rect.height, }; } /** Grow `rect` to `width` about its anchor, keeping the aspect ratio. */ function rectFromWidth(rect, corner, width) { const anchor = anchorOf(rect, corner); const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; const height = width * ASPECT; return { x: right ? anchor.x : anchor.x - width, y: bottom ? anchor.y : anchor.y - height, width, height, }; } /** Screen position of one corner of a rect, same indexing as resizeRect. */ function cornerOf(rect, corner) { const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; return { x: right ? rect.x + rect.width : rect.x, y: bottom ? rect.y + rect.height : rect.y, }; } function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); } /** Longer trips take longer, but not proportionally — Fitts, roughly. */ function reachTime(px) { return clamp(0.28 + px / 1500, 0.28, 1.2); } /** Dragging is slower than moving an empty hand, and much slower than a flick. */ function dragTime(px) { return clamp(0.55 + px / 700, 0.55, 2.4); } export function startDragResize({ element }) { const pointer = document.createElement('div'); pointer.id = 'pointer'; pointer.innerHTML = ARROW; document.body.appendChild(pointer); let base = frameSize(window.innerWidth, window.innerHeight, DRAG_FRAME_SCALE); let rect = { x: (window.innerWidth - base.width) / 2, y: (window.innerHeight - base.height) / 2, width: base.width, height: base.height, }; // The pointer starts off screen and slides in for its first grab. let at = { x: -40, y: window.innerHeight * 0.75 }; function margin() { return Math.min(window.innerWidth, window.innerHeight) * CORNER_MARGIN; } /** Widths the frame may take: never wider than the screen allows. */ function widthLimits() { const fit = Math.min( window.innerWidth - 2 * margin(), (window.innerHeight - 2 * margin()) / ASPECT, ); const min = Math.min(base.width * DRAG_MIN_SCALE, fit); return { min, max: clamp(base.width * DRAG_MAX_SCALE, min, fit) }; } function draw() { element.style.width = `${rect.width.toFixed(1)}px`; element.style.height = `${rect.height.toFixed(1)}px`; place(element, rect.x, rect.y); place(pointer, at.x, at.y); } // --- the script --- // // One cycle is a fixed sequence of beats; each beat gets its endpoints when // it starts, so a viewport resize part-way through never strands the pointer // at coordinates that no longer exist. let steps = []; let index = 0; let elapsed = 0; // Filled in by the press beats and read by the drag beats that follow. let held = { x: 0, y: 0 }; let corner = 0; let anchored = rect; function travel(to, options) { const from = at; const target = to(); return { duration: options.time(distance(from, target)), update(p) { at = pointerAt(from, target, p, options); options.moved?.(); }, }; } /** Grab: the pointer lands on the frame's title-bar strip. */ function reachFrame() { return travel( () => ({ x: rect.x + between(0.2, 0.8) * rect.width, y: rect.y + between(GRAB_BAND[0], GRAB_BAND[1]) * rect.height, }), // An empty hand shoots past the target and pulls back onto it. { time: reachTime, curve: between(-0.07, 0.07), overshoot: 1.6 }, ); } function reachCorner() { corner = Math.floor(Math.random() * CORNERS); return travel(() => cornerOf(rect, corner), { time: reachTime, curve: between(-0.05, 0.05), overshoot: 1.4, }); } function press() { return { duration: between(0.1, 0.22), begin() { pointer.classList.add('down'); element.classList.add('dragging'); held = { x: at.x - rect.x, y: at.y - rect.y }; anchored = { ...rect }; }, update() {}, }; } function release() { const from = at; return { duration: between(PAUSE[0], PAUSE[1]), begin() { pointer.classList.remove('down'); element.classList.remove('dragging'); }, // A resting hand is never quite still. update(p) { at = { x: from.x + Math.sin(p * 7) * IDLE_DRIFT, y: from.y + Math.sin(p * 5 + 1) * IDLE_DRIFT * 0.7, }; }, }; } /** Drag: the frame hangs off the pointer at the offset it was grabbed by. */ function dragFrame() { const gap = margin(); return travel( () => { const limitX = Math.max(gap, window.innerWidth - rect.width - gap); const limitY = Math.max(gap, window.innerHeight - rect.height - gap); return { x: between(gap, limitX) + held.x, y: between(gap, limitY) + held.y, }; }, { time: dragTime, curve: between(-0.04, 0.04), // A loaded hand overshoots less than an empty one. overshoot: 0.8, moved() { rect.x = at.x - held.x; rect.y = at.y - held.y; }, }, ); } /** Resize: the held corner follows the pointer, the opposite one stays put. */ function dragCorner() { const limits = widthLimits(); const gap = margin(); return travel( () => { // How far the corner can travel before it leaves the screen — the same // wall a real hand would run the window into. The anchor never moves, // so the room is measured from there. const anchor = anchorOf(anchored, corner); const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; const room = Math.min( right ? window.innerWidth - gap - anchor.x : anchor.x - gap, (bottom ? window.innerHeight - gap - anchor.y : anchor.y - gap) / ASPECT, ); const width = clamp( between(limits.min, limits.max), limits.min, Math.max(limits.min, room), ); return cornerOf(rectFromWidth(anchored, corner, width), corner); }, { time: dragTime, curve: between(-0.03, 0.03), // Least of all when pulling against a corner: the size lands and stays. overshoot: 0.5, moved() { rect = resizeRect(anchored, corner, at, limits); }, }, ); } // Beats are built lazily: each one reads the frame where the previous one // left it. const CYCLE = [reachFrame, press, dragFrame, release, reachCorner, press, dragCorner, release]; function step() { const build = CYCLE[index % CYCLE.length]; index += 1; const current = build(); current.begin?.(); return current; } let current = step(); function measure() { base = frameSize(window.innerWidth, window.innerHeight, DRAG_FRAME_SCALE); const gap = margin(); const limits = widthLimits(); rect.width = clamp(rect.width, limits.min, limits.max); rect.height = rect.width * ASPECT; rect.x = clamp(rect.x, gap, Math.max(gap, window.innerWidth - rect.width - gap)); rect.y = clamp(rect.y, gap, Math.max(gap, window.innerHeight - rect.height - gap)); // Whatever the pointer was aiming at is stale now; start the next beat // from where things actually are. pointer.classList.remove('down'); element.classList.remove('dragging'); elapsed = 0; current = step(); } window.addEventListener('resize', measure); let last = performance.now(); let handle = 0; let stopped = false; function frame(now) { if (stopped) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; elapsed += dt; const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1; current.update(p); draw(); if (p >= 1) { elapsed = 0; current = step(); } handle = requestAnimationFrame(frame); } draw(); handle = requestAnimationFrame(frame); return function stop() { stopped = true; cancelAnimationFrame(handle); window.removeEventListener('resize', measure); element.classList.remove('dragging'); pointer.remove(); // The next animation sizes the frame itself, but leaving a stretched frame // up for even one paint would flash. applyFrameSize(element); }; } // --- colour wash -------------------------------------------------------- // The frame parks in the middle and the room changes colour around it: a wash // creeps round the colour wheel behind the poster, and the frame's glow travels // with it, so the whole screen shifts as one. It is the quiet one — everything // else in the registry moves the frame, and after a few of those a still frame // with a slow colour behind it is a relief rather than a dead spot. /** * The two ends of the wash gradient at `elapsed` seconds, as hues in degrees. * One trip round the wheel takes WASH_CYCLE_SECONDS, and the second hue trails * the first by WASH_SPREAD so the gradient always has somewhere to go. Pure, so * the cycle can be exercised without a browser. */ export function washHues(elapsed) { const turn = ((elapsed / WASH_CYCLE_SECONDS) * 360) % 360; return [turn, (turn + WASH_SPREAD) % 360]; } /** A wash stop as a colour: saturated and mid-bright, before the blend mode. */ function washStop(hue, alpha = 1) { return `hsl(${hue.toFixed(1)} 85% 55% / ${alpha})`; } // The frame's glow is the wash colour held back to the same weight the fixed // pink had, so a hue with more punch to it does not brighten the whole frame. const GLOW_ALPHA = 0.55; export function startWash({ element }) { const wash = document.createElement('div'); wash.id = 'wash'; wash.style.opacity = String(WASH_STRENGTH); // Behind the stills and the frame, in front of the poster it tints. document.body.appendChild(wash); element.classList.add('washing'); function centre() { const size = applyFrameSize(element, WASH_FRAME_SCALE); place(element, (window.innerWidth - size.width) / 2, (window.innerHeight - size.height) / 2); } centre(); window.addEventListener('resize', centre); const started = performance.now(); let handle = 0; let stopped = false; function frame(now) { if (stopped) return; const [from, to] = washHues((now - started) / 1000); wash.style.background = `linear-gradient(135deg, ${washStop(from)}, ${washStop(to)})`; // The frame's glow rides the leading hue, so it belongs to the wash instead // of sitting on top of it in a fixed pink. element.style.setProperty('--wash', washStop(from, GLOW_ALPHA)); handle = requestAnimationFrame(frame); } handle = requestAnimationFrame(frame); return function stop() { stopped = true; cancelAnimationFrame(handle); window.removeEventListener('resize', centre); wash.remove(); element.classList.remove('washing'); element.style.removeProperty('--wash'); // Same reason as the drag animation: the next one sizes the frame itself, // but a wash-sized frame left up for one paint would flash. applyFrameSize(element); }; } // --- pong --------------------------------------------------------------- // The 1972 game, with the video frame cast as the paddle: it slides along the // bottom of the screen and nothing else, a square ball runs the diagonals off // the walls, and every ball that gets past the frame puts one on the counter in // the top right — in seven-segment digits, because that is what the era had. // Which segments each digit lights, named the standard way: a top, b top right, // c bottom right, d bottom, e bottom left, f top left, g middle. export const DIGIT_SEGMENTS = { 0: 'abcdef', 1: 'bc', 2: 'abdeg', 3: 'abcdg', 4: 'bcfg', 5: 'acdfg', 6: 'acdefg', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg', }; // Geometry of one digit, in its own 24x44 viewBox: the segments are chamfered // bars, so the ends of two neighbours meet in a mitre the way a real display's // do rather than butting into each other. const SEG_THICKNESS = 4.4; const SEG_LEFT = 3; const SEG_RIGHT = 21; const SEG_TOP = 3; const SEG_MIDDLE = 22; const SEG_BOTTOM = 41; function horizontalSegment(y) { const t = SEG_THICKNESS / 2; return [ [SEG_LEFT, y], [SEG_LEFT + t, y - t], [SEG_RIGHT - t, y - t], [SEG_RIGHT, y], [SEG_RIGHT - t, y + t], [SEG_LEFT + t, y + t], ]; } function verticalSegment(x, top, bottom) { const t = SEG_THICKNESS / 2; return [ [x, top], [x + t, top + t], [x + t, bottom - t], [x, bottom], [x - t, bottom - t], [x - t, top + t], ]; } const SEGMENT_SHAPES = { a: horizontalSegment(SEG_TOP), b: verticalSegment(SEG_RIGHT, SEG_TOP, SEG_MIDDLE), c: verticalSegment(SEG_RIGHT, SEG_MIDDLE, SEG_BOTTOM), d: horizontalSegment(SEG_BOTTOM), e: verticalSegment(SEG_LEFT, SEG_MIDDLE, SEG_BOTTOM), f: verticalSegment(SEG_LEFT, SEG_TOP, SEG_MIDDLE), g: horizontalSegment(SEG_MIDDLE), }; /** One seven-segment digit as an , with every segment always present. */ function createDigit() { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 44'); svg.classList.add('digit'); const cells = {}; for (const [name, points] of Object.entries(SEGMENT_SHAPES)) { const shape = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); shape.setAttribute('points', points.map(([x, y]) => `${x},${y}`).join(' ')); // Dark segments stay drawn rather than disappearing: an unlit segment is // what makes the display read as one, instead of as a floating shape. shape.classList.add('segment'); svg.appendChild(shape); cells[name] = shape; } return { svg, show(digit) { const lit = DIGIT_SEGMENTS[digit] ?? ''; for (const [name, shape] of Object.entries(cells)) { shape.classList.toggle('lit', lit.includes(name)); } } }; } /** The score board: `digits` seven-segment cells, right-aligned like a counter. */ function createScoreboard(digits = 2) { const board = document.createElement('div'); board.id = 'pongScore'; const cells = []; for (let i = 0; i < digits; i += 1) { const cell = createDigit(); board.appendChild(cell.svg); cells.push(cell); } return { element: board, show(value) { // Rolls over rather than growing a digit: the layout stays put, and a // party is not going to get there anyway. const text = String(Math.floor(value) % 10 ** digits).padStart(digits, '0'); cells.forEach((cell, i) => cell.show(Number(text[i]))); }, }; } /** * The ball: constant-speed travel that reflects off the left, right and top * walls. The bottom is deliberately open — getting past it is the whole point * of the game — so `step` reports when the ball has left the screen there. * Pure state + step, so a rally can be played out without a browser. */ export function createPongBall({ x = 0, y = 0, vx = 0, vy = 0 } = {}) { const ball = { x, y, vx, vy, step(dt, bounds) { const maxX = Math.max(0, bounds.width); ball.x += ball.vx * dt; ball.y += ball.vy * dt; if (ball.x <= 0) { ball.x = Math.min(-ball.x, maxX); ball.vx = Math.abs(ball.vx); } else if (ball.x >= maxX) { ball.x = Math.max(maxX - (ball.x - maxX), 0); ball.vx = -Math.abs(ball.vx); } if (ball.y <= 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); } return ball; }, }; return ball; } /** * The bounce off the frame, if the ball has run into it during a step that took * it from `previous` to where it is now. The frame is solid on all four sides: * a ball that comes at its flank or reaches up under it from below is turned * away just as a ball landing on its face is. Returns the corrected position and * velocity, or null if the ball is clear of the frame. * * The side it came in on is decided by where it was before the step, and — for * a frame that slid into a ball rather than the other way round — by which way * out of the overlap is shortest. Pushing the ball back out along that side is * what stops it from tunnelling into the frame and rattling there. * * On the face, where the ball lands sets the angle it leaves at, as in the * original: the middle sends it straight back up, the edges send it out to * PONG_MAX_ANGLE. That is what lets a rally develop instead of the ball * retracing one line forever. The other three sides are plain reflections — * the frame is a wall there, not a bat. */ export function frameBounce(ball, size, previous, frame, speed = PONG_BALL_SPEED) { const right = frame.x + frame.width; const bottom = frame.y + frame.height; // Clear of it: nothing to do. if (ball.x + size <= frame.x || ball.x >= right) return null; if (ball.y + size <= frame.y || ball.y >= bottom) return null; // Where the ball was before the step decides the side, so a ball crossing the // frame's face in one step still bounces off the face. const from = previous.y + size <= frame.y ? 'top' : previous.y >= bottom ? 'bottom' : previous.x + size <= frame.x ? 'left' : previous.x >= right ? 'right' : shallowestSide(ball, size, frame); if (from === 'top') { const half = frame.width / 2; const offset = clamp((ball.x + size / 2 - (frame.x + half)) / half, -1, 1); const angle = (offset * PONG_MAX_ANGLE * Math.PI) / 180; return { x: ball.x, y: frame.y - size, vx: Math.sin(angle) * speed, vy: -Math.cos(angle) * speed, }; } if (from === 'bottom') { return { x: ball.x, y: bottom, vx: ball.vx, vy: Math.abs(ball.vy) }; } return { x: from === 'left' ? frame.x - size : right, y: ball.y, vx: from === 'left' ? -Math.abs(ball.vx) : Math.abs(ball.vx), vy: ball.vy, }; } /** The side of `frame` the overlapping ball is nearest to getting back out of. */ function shallowestSide(ball, size, frame) { const out = { top: ball.y + size - frame.y, bottom: frame.y + frame.height - ball.y, left: ball.x + size - frame.x, right: frame.x + frame.width - ball.x, }; return Object.keys(out).reduce((best, side) => (out[side] < out[best] ? side : best)); } /** * Where the ball's left edge will be when its underside reaches `faceY`, walls * included. The reflections are unfolded rather than simulated: a straight line * is folded back into the playable strip, which is the same answer a step-by- * step run would give and costs nothing to compute. Null while the ball is on * its way up, since there is nothing to defend yet. */ export function predictLanding(ball, size, faceY, width) { if (ball.vy <= 0) return null; const span = Math.max(0, width - size); if (span === 0) return 0; const time = (faceY - size - ball.y) / ball.vy; const far = ball.x + ball.vx * time; // Fold into 0..span: the pattern repeats every two crossings of the strip. const period = span * 2; const wrapped = ((far % period) + period) % period; return wrapped > span ? period - wrapped : wrapped; } export function startPong({ element }) { const layer = document.createElement('div'); layer.id = 'pong'; const ballElement = document.createElement('div'); ballElement.id = 'pongBall'; layer.appendChild(ballElement); const score = createScoreboard(); layer.appendChild(score.element); document.body.appendChild(layer); element.classList.add('paddle'); let misses = 0; score.show(misses); let size = applyFrameSize(element, PONG_FRAME_SCALE); let ballSize = 0; let faceY = 0; // The paddle's own state: where it is, and where it has decided to be. The // aim is only recomputed when the ball turns downward, so the paddle commits // to one destination per rally instead of twitching after every wall bounce. let paddleX = 0; let aim = null; const ball = createPongBall(); function measure() { size = applyFrameSize(element, PONG_FRAME_SCALE); const short = Math.min(window.innerWidth, window.innerHeight); ballSize = Math.round(short * PONG_BALL_SIZE); faceY = window.innerHeight - short * PONG_PADDLE_MARGIN - size.height; ballElement.style.width = `${ballSize}px`; ballElement.style.height = `${ballSize}px`; paddleX = clamp(paddleX, 0, Math.max(0, window.innerWidth - size.width)); ball.x = clamp(ball.x, 0, Math.max(0, window.innerWidth - ballSize)); // Whatever the paddle was aiming at was measured against the old screen. aim = null; } /** Put the ball back in play from the top, on a fresh diagonal. */ function serve() { const angle = ((between(25, 60) * (Math.random() < 0.5 ? -1 : 1)) * Math.PI) / 180; ball.x = between(0.2, 0.8) * Math.max(0, window.innerWidth - ballSize); ball.y = 0; ball.vx = Math.sin(angle) * PONG_BALL_SPEED; ball.vy = Math.abs(Math.cos(angle)) * PONG_BALL_SPEED; aim = null; } /** * Pick the spot the paddle drives to for this descent. Most rallies it aims * to meet the ball a little off centre; now and then it aims a paddle's width * wide, which is what puts a number on the counter. */ function decideAim() { const landing = predictLanding(ball, ballSize, faceY, window.innerWidth); if (landing === null) return; const centre = landing + ballSize / 2; const half = size.width / 2; if (Math.random() < PONG_MISS_CHANCE) { // Just wide, not absent: the frame still arrives, and the ball clips past // its edge, which reads as a miss rather than as the paddle giving up. const side = centre < window.innerWidth / 2 ? 1 : -1; aim = centre + side * (half + ballSize * between(1.2, 2.5)); } else { aim = centre + between(-half * 0.6, half * 0.6); } } function draw() { place(element, paddleX, faceY); place(ballElement, ball.x, ball.y); } measure(); paddleX = (window.innerWidth - size.width) / 2; serve(); draw(); window.addEventListener('resize', measure); let last = performance.now(); let waiting = 0; let handle = 0; let stopped = false; function frame(now) { if (stopped) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; // Out of play between the goal and the serve, so the score has a beat to be // read before the next rally starts. if (waiting > 0) { waiting -= dt; if (waiting <= 0) { serve(); ballElement.classList.remove('out'); } } else { const was = { x: ball.x, y: ball.y }; const wasDown = ball.vy > 0; ball.step(dt, { width: window.innerWidth - ballSize }); const hit = frameBounce(ball, ballSize, was, { x: paddleX, y: faceY, width: size.width, height: size.height, }); if (hit) { ball.x = hit.x; ball.y = hit.y; ball.vx = hit.vx; ball.vy = hit.vy; aim = null; } else if (ball.y > window.innerHeight) { misses += 1; score.show(misses); ballElement.classList.add('out'); waiting = PONG_SERVE_SECONDS; } else if (ball.vy > 0 && (!wasDown || aim === null)) { decideAim(); } } // The paddle keeps moving whatever the ball is doing: mid-serve it slides // back toward the middle, ready for whichever way the next one comes. const target = aim === null ? window.innerWidth / 2 : aim; const limit = Math.max(0, window.innerWidth - size.width); const reach = PONG_PADDLE_SPEED * dt; paddleX = clamp( paddleX + clamp(target - size.width / 2 - paddleX, -reach, reach), 0, limit, ); draw(); handle = requestAnimationFrame(frame); } handle = requestAnimationFrame(frame); return function stop() { stopped = true; cancelAnimationFrame(handle); window.removeEventListener('resize', measure); layer.remove(); element.classList.remove('paddle'); // Same as the drag and wash animations: the next one sizes the frame // itself, but a paddle-sized frame left up for one paint would flash. applyFrameSize(element); }; } // --- registry ----------------------------------------------------------- // Keyed by the names in config.js, which is what travels over the socket. export const ANIMATIONS = { bounce: startBounce, corners: startCorners, drag: startDragResize, pong: startPong, trail: startTrail, wash: startWash, }; const NAMES = Object.keys(ANIMATIONS); /** * Run one animation at a time. In auto mode next() swaps to a freshly picked * one on every track change; pinned to a name (from the queue page, for * testing) it stays put until the mode changes again. */ export function startAnimationCycle(context, mode = AUTO_ANIMATION) { const picker = createPicker(NAMES.length); let stop = null; let current = null; function run(name) { stop?.(); current = name; stop = ANIMATIONS[name](context); return current; } function next() { // Pinned: the track changed, but the operator asked for this one. if (mode !== AUTO_ANIMATION) return current; return run(NAMES[picker.next()]); } function setMode(value) { mode = ANIMATIONS[value] ? value : AUTO_ANIMATION; // Always restarts, so switching back to auto takes effect immediately // rather than waiting out the current track. return mode === AUTO_ANIMATION ? run(NAMES[picker.next()]) : run(mode); } setMode(mode); return { next, setMode, get mode() { return mode; }, get name() { return current; }, }; }