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;