| /* Straight-line tile flights. | |
| * | |
| * Every movement on this table is the same gesture: a tile leaves one place and | |
| * travels in a straight line to another. Five things move — tiles from a dish | |
| * to a board, a dish's leftovers to the middle, the first-player marker, a full | |
| * pattern line to the wall, and the floor line to the lid — and all of them are | |
| * the same call. | |
| * | |
| * A flight is a clone in a fixed-position layer: the real tiles are hidden for | |
| * the duration, and the page re-renders from the new state when the group | |
| * lands. Nothing here knows what a move *means*; callers pass elements (or | |
| * rectangles) and a colour. | |
| * | |
| * ───────────────────────────────────────────────────────────────────────────── | |
| * WHY THERE IS NO `prefers-reduced-motion` CHECK IN THIS FILE | |
| * | |
| * There used to be one, and it was a bug with a long tail. `flyTiles` returned | |
| * immediately when the OS asked for reduced motion, `sleep` collapsed to zero, | |
| * and board.css additionally forced `transition-duration: .001ms !important` on | |
| * everything. macOS ships "Reduce motion" switched on for a lot of people, and | |
| * Safari and Chrome both forward it, so those players saw tiles teleport with | |
| * no way whatsoever to ask for the animation back — while every headless test | |
| * passed, because the test harness set `no-preference` first. | |
| * | |
| * Motion is now governed by one thing only: the speed setting in the page | |
| * (`speed()` / `setSpeed()` below, persisted in localStorage). The OS flag is | |
| * still readable through `prefersReducedMotion()` so the settings panel can | |
| * *mention* it, but it never silences anything on its own. | |
| * ───────────────────────────────────────────────────────────────────────────── */ | |
| export const FLIGHT_MS = 460; // one group of tiles, door to door, at 1× | |
| export const STAGGER_MS = 45; // ... plus this per tile after the first | |
| export const LAND_MS = 90; // a beat to let the last tile settle | |
| const STORE_KEY = "ludometer.anim.speed"; | |
| /** What the settings panel offers. 0 means "no animation at all". */ | |
| export const SPEEDS = [0, 0.5, 1, 2]; | |
| export const DEFAULT_SPEED = 1; | |
| let current = DEFAULT_SPEED; | |
| const listeners = []; | |
| function readStored() { | |
| try { | |
| const raw = window.localStorage.getItem(STORE_KEY); | |
| if (raw === null) return DEFAULT_SPEED; | |
| const value = Number(raw); | |
| return SPEEDS.indexOf(value) === -1 ? DEFAULT_SPEED : value; | |
| } catch (err) { | |
| return DEFAULT_SPEED; // private mode, a locked-down profile: animate anyway | |
| } | |
| } | |
| /** The current animation speed: 0 (off), 0.5 (slower), 1 (default) or 2 (fast). */ | |
| export const speed = () => current; | |
| /** Whether tiles move at all. */ | |
| export const animated = () => current > 0; | |
| /** | |
| * Choose a speed. Persists it, reflects it on `<body data-anim>` (so CSS can | |
| * see it) and tells every listener. | |
| */ | |
| export function setSpeed(value) { | |
| const next = SPEEDS.indexOf(Number(value)) === -1 ? DEFAULT_SPEED : Number(value); | |
| current = next; | |
| try { | |
| window.localStorage.setItem(STORE_KEY, String(next)); | |
| } catch (err) { | |
| /* not being able to remember it is not a reason to refuse it */ | |
| } | |
| if (document.body) document.body.dataset.anim = next === 0 ? "off" : String(next); | |
| listeners.forEach((fn) => fn(next)); | |
| return next; | |
| } | |
| export function onSpeedChange(fn) { | |
| listeners.push(fn); | |
| return () => listeners.splice(listeners.indexOf(fn), 1); | |
| } | |
| /** Load the stored speed. Call once, early, before anything animates. */ | |
| export function initSpeed() { | |
| return setSpeed(readStored()); | |
| } | |
| /** How long `ms` at 1× lasts at the current speed. Zero when animation is off. */ | |
| export function scaled(ms) { | |
| if (!current) return 0; | |
| return ms / current; | |
| } | |
| /** | |
| * Whether the *operating system* asks for reduced motion. | |
| * | |
| * Informational only — see the note at the top of this file. The settings panel | |
| * uses it to explain itself; nothing else may branch on it. | |
| */ | |
| export const prefersReducedMotion = () => | |
| !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); | |
| /** A pause in a turn sequence, in 1× milliseconds. */ | |
| export const sleep = (ms) => | |
| new Promise((done) => setTimeout(done, scaled(ms))); | |
| /** A rectangle for an element, or the rectangle itself if one is passed. */ | |
| export function rectOf(target) { | |
| if (!target) return null; | |
| if (typeof target.getBoundingClientRect === "function") { | |
| const r = target.getBoundingClientRect(); | |
| if (!r.width && !r.height) return null; | |
| return r; | |
| } | |
| return target; | |
| } | |
| /** | |
| * Fly `flights` and resolve once they have landed. | |
| * | |
| * Each flight is `{from, to, color, hide}`: `from`/`to` are elements or client | |
| * rectangles, `color` is 0..4 (or "marker"), `hide` defaults to true and hides | |
| * the source element while its clone is in the air. | |
| * | |
| * `options`: `{layer, duration, stagger, scale}` — `duration` and `stagger` are | |
| * 1× milliseconds and are scaled by the speed setting here. Returns a promise | |
| * that always resolves (never rejects) so a caller can `await` it in a turn | |
| * sequence. | |
| */ | |
| export function flyTiles(flights, options = {}) { | |
| const layer = options.layer; | |
| const baseDuration = options.duration === undefined ? FLIGHT_MS : options.duration; | |
| const baseStagger = options.stagger === undefined ? STAGGER_MS : options.stagger; | |
| if (!layer || !flights || !flights.length || !animated()) return Promise.resolve(); | |
| const duration = scaled(baseDuration); | |
| const stagger = scaled(baseStagger); | |
| const airborne = []; | |
| flights.forEach((flight, i) => { | |
| const a = rectOf(flight.from); | |
| const b = rectOf(flight.to); | |
| if (!a || !b) return; | |
| const ghost = document.createElement("div"); | |
| ghost.className = "fly-tile " + (flight.color === "marker" ? "marker" : "tile"); | |
| if (flight.color !== "marker") ghost.dataset.color = flight.color; | |
| else ghost.textContent = "1"; | |
| ghost.style.left = a.left + "px"; | |
| ghost.style.top = a.top + "px"; | |
| ghost.style.width = a.width + "px"; | |
| ghost.style.height = a.height + "px"; | |
| ghost.style.transitionDuration = duration + "ms"; | |
| // the delay is per *airborne* clone, not per requested flight, so a skipped | |
| // flight (source already gone) never leaves a clone waiting past `total` | |
| const delay = airborne.length * stagger; | |
| ghost.style.transitionDelay = delay + "ms"; | |
| layer.appendChild(ghost); | |
| if (flight.hide !== false && flight.from && flight.from.style) { | |
| flight.from.style.visibility = "hidden"; | |
| } | |
| // A tile stays a tile in the air. The destination is sometimes a whole | |
| // panel (the lid, the centre dish), and matching its width used to blow the | |
| // clone up to the width of the page mid-flight — so the scale is clamped to | |
| // "slightly bigger or smaller", never "as big as whatever it lands in". | |
| const raw = options.scale === false ? 1 : b.width / (a.width || 1); | |
| const scale = Math.max(0.6, Math.min(1.25, raw)); | |
| const dx = b.left + (b.width - a.width * scale) / 2 - a.left; | |
| const dy = b.top + (b.height - a.height * scale) / 2 - a.top; | |
| airborne.push([ghost, dx, dy, scale, delay]); | |
| }); | |
| if (!airborne.length) return Promise.resolve(); | |
| const total = duration + (airborne.length - 1) * stagger + scaled(LAND_MS); | |
| return new Promise((done) => { | |
| requestAnimationFrame(() => { | |
| // Commit every clone's starting position before any of them moves. The | |
| // clones were styled and appended in the same task that is about to set | |
| // their transforms; without a forced style flush the browser's first | |
| // recalc would see only the final state, and whichever clone nothing | |
| // else happened to flush — always the last one — would skip its | |
| // transition and simply appear at the destination. | |
| void layer.offsetWidth; | |
| airborne.forEach(([ghost, dx, dy, scale, delay]) => { | |
| ghost.style.transform = | |
| "translate(" + dx + "px, " + dy + "px) scale(" + scale.toFixed(3) + ")"; | |
| // each tile seats the moment *it* lands — the raised in-flight shadow | |
| // drops then, not when the whole group has finished arriving | |
| setTimeout(() => ghost.classList.add("landed"), duration + delay); | |
| }); | |
| setTimeout(() => { | |
| airborne.forEach(([ghost]) => ghost.remove()); | |
| done(); | |
| }, total); | |
| }); | |
| }); | |
| } | |
| /** Total wall time `flyTiles` will take for `count` tiles — for pacing a turn. */ | |
| export function flightDuration(count, duration = FLIGHT_MS, stagger = STAGGER_MS) { | |
| if (!count || !animated()) return 0; | |
| return scaled(duration + (count - 1) * stagger + LAND_MS); | |
| } | |