From bb5f87ff05a6b95ca771877abc15a90ca94bd873 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 10 Aug 2026 20:29:01 +0200 Subject: [PATCH] Add a pong animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9 --- README.md | 10 +- config.js | 29 +++ public/player/motion.js | 424 +++++++++++++++++++++++++++++++++++++++ public/player/player.css | 61 ++++++ 4 files changed, 523 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f684aa..852c05d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ autoplay, so the first click is what unlocks audio. One click per session. ## Animations -The video frame's movement changes with every track. Five animations ship +The video frame's movement changes with every track. Six animations ship today: - **bounce** — diagonal drift, reflecting off the screen edges. @@ -51,6 +51,14 @@ today: one corner and pulls the frame to a new size. The aspect ratio stays locked and the frame stays on screen, so the resize reads as a window resize rather than a stretch. +- **pong** — the 1972 game with the frame cast as the paddle: it slides along + the bottom of the screen and nowhere else, a square ball runs the diagonals + off the walls, and where the ball lands on the frame's face sets the angle it + leaves at. The frame is solid on all four sides, so a ball that comes at its + flank or up under it is turned away too. It plays the ball's predicted + landing spot, but aims a width wide + once in a while, and every ball that gets past it puts one on the + seven-segment counter in the top right. - **trail** — the frame glides along a curving path, steering from waypoint to waypoint, and drops a screenshot of itself every frame-width or so. The stills stay where they were taken and fade over a few seconds, leaving a comet tail of diff --git a/config.js b/config.js index 1cbb45c..f3e0a14 100644 --- a/config.js +++ b/config.js @@ -60,12 +60,41 @@ export const WASH_STRENGTH = 0.55; // has the screen to itself, so it does not have to leave room for a second one. export const WASH_FRAME_SCALE = 0.6; +// Pong: the frame is the paddle, so it gets its own size — narrow enough that +// it can miss, wide enough that the video in it is still worth watching. +export const PONG_FRAME_SCALE = 0.3; + +// How fast the ball travels, and how fast the paddle may slide, in pixels per +// second. The paddle has to outrun the ball to defend a wide screen, but not by +// so much that it teleports under every shot. +export const PONG_BALL_SPEED = 420; +export const PONG_PADDLE_SPEED = 760; + +// The ball is a square this fraction of the screen's short side. +export const PONG_BALL_SIZE = 0.028; + +// Gap between the paddle and the bottom edge, as a fraction of the short side. +export const PONG_PADDLE_MARGIN = 0.04; + +// Steepest angle off the paddle, in degrees from straight up. Hitting the ball +// with the edge of the paddle sends it out this far. +export const PONG_MAX_ANGLE = 60; + +// How often the paddle deliberately comes up short, so the counter in the +// corner actually moves. Without this a paddle that outruns the ball never +// loses a rally. +export const PONG_MISS_CHANCE = 0.25; + +// Seconds the ball waits out of play after it gets past the paddle. +export const PONG_SERVE_SECONDS = 1.1; + // The frame animations, keyed by the id sent over the socket. The names must // match the registry in public/player/motion.js, which supplies the code. export const ANIMATIONS = { bounce: { label: 'Bounce' }, corners: { label: 'Corners' }, drag: { label: 'Drag & resize' }, + pong: { label: 'Pong' }, trail: { label: 'Comet trail' }, wash: { label: 'Colour wash' }, }; diff --git a/public/player/motion.js b/public/player/motion.js index 35edb36..5cd54b3 100644 --- a/public/player/motion.js +++ b/public/player/motion.js @@ -23,6 +23,14 @@ import { 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'; @@ -982,6 +990,421 @@ export function startWash({ 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. @@ -989,6 +1412,7 @@ export const ANIMATIONS = { bounce: startBounce, corners: startCorners, drag: startDragResize, + pong: startPong, trail: startTrail, wash: startWash, }; diff --git a/public/player/player.css b/public/player/player.css index 6f6ddaf..3f85ae1 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -152,6 +152,67 @@ body { transform: scale(0.82); } +/* --- pong --------------------------------------------------------------- */ + +/* Built and removed by the pong animation. Above the frame, so the ball is + never hidden behind the paddle it is bouncing off. */ +#pong { + position: fixed; + inset: 0; + z-index: 3; + pointer-events: none; +} + +#pongBall { + position: absolute; + top: 0; + left: 0; + background: #fff; + /* Square, and no smoothing: the original had no room for a round ball. */ + box-shadow: 0 0 18px rgba(255, 79, 195, 0.7); + will-change: transform; +} + +/* Out of play between a goal and the next serve. */ +#pongBall.out { + opacity: 0; +} + +#pongScore { + position: absolute; + top: clamp(16px, 4vh, 56px); + right: clamp(16px, 4vw, 72px); + display: flex; + gap: 0.35em; + font-size: clamp(2.5rem, 7vh, 6rem); +} + +#pongScore .digit { + display: block; + height: 1em; + /* The viewBox is 24 wide by 44 tall. */ + width: calc(1em * 24 / 44); +} + +/* An unlit segment stays visible, just barely — that faint ghost of the whole + figure eight is what makes the display read as a seven-segment one. */ +#pongScore .segment { + fill: rgba(255, 255, 255, 0.07); +} + +#pongScore .segment.lit { + fill: #fff; + filter: drop-shadow(0 0 10px rgba(255, 79, 195, 0.8)); +} + +/* The paddle: a hard-edged slab, so the ball has something square to hit. */ +#frameMover.paddle #frameSkin { + border-radius: 0; + box-shadow: + 0 0 0 3px #fff, + 0 0 34px rgba(255, 79, 195, 0.6); +} + .deck { position: absolute; inset: 0;