Files
party-player/public/player/motion.js
T
simonandClaude Opus 5 bb5f87ff05 Add a pong animation
The video frame plays the paddle: it slides along the bottom of the
screen and nowhere else, a square ball runs the diagonals off the walls,
and every ball that gets past the frame puts one on a seven-segment
counter in the top right.

The frame is solid on all four sides, not just its face, so a ball that
comes at its flank or up under it is turned away too. Which side a hit
came in on is read from where the ball was before the step, with a
shallowest-penetration fallback for the frame sliding into the ball
rather than the other way round — without that the ball tunnels in and
rattles inside the frame. The face keeps the original's angle rule, where
the hit position sets the departure angle, so rallies develop instead of
the ball retracing one line; the other three sides are plain reflections.

The paddle steers by predictLanding, which unfolds the wall reflections
analytically instead of simulating them. A paddle that outruns the ball
never loses, so PONG_MISS_CHANCE of the time it aims a width wide and
lets one through — otherwise the counter would sit on 00 all night.

The digits are SVG polygons built from a segment map, so the era-correct
display costs no font file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 20:29:01 +02:00

1465 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `<svg viewBox="0 0 24 24" width="27" height="27" aria-hidden="true">
<path d="M5 2.5 L5 19.6 L9.5 15.4 L12.3 21.5 L15.2 20.1 L12.4 14.2 L18.6 14 Z"
fill="#fff" stroke="#14060f" stroke-width="1.3" stroke-linejoin="round" />
</svg>`;
// 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 <svg>, 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;
},
};
}