Files
party-player/public/player/motion.js
T
simonandClaude Opus 5 1afe6580ae Stack corner stills at staggered offsets
A full corner used to swap its single still in place, so a fresh
screenshot was indistinguishable from the one it replaced. Each corner
now holds three stills nudged inward by 9% of the frame per depth, and
the newest one moves to the top of the layer, leaving the edges of the
ones below visible. Fill order is slot-major, so a new shot never lands
where the previous shot at that corner was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:19:30 +02:00

352 lines
11 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,
AUTO_ANIMATION,
} from '/config.js';
const ASPECT = 9 / 16;
/**
* Frame size for a viewport: FRAME_SCALE of the width, but never taller than
* FRAME_SCALE of the height, so a wide screen does not get a letterbox strip.
*/
export function frameSize(viewportWidth, viewportHeight) {
let width = viewportWidth * FRAME_SCALE;
let height = width * ASPECT;
const maxHeight = viewportHeight * FRAME_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) {
const size = frameSize(window.innerWidth, window.innerHeight);
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();
};
}
// --- registry -----------------------------------------------------------
// Keyed by the names in config.js, which is what travels over the socket.
export const ANIMATIONS = {
bounce: startBounce,
corners: startCorners,
};
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;
},
};
}