clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / src / render / menu-motion.js
menu-motion.js 4.5K
1const OPEN_DURATION = 280;2const SHUTTER_DURATION = 140;3const DROP_DURATION = 200;4const DROP_STAGGER = 35;5const EXIT_DURATION = 230;6const EXIT_ITEM_DURATION = 170;7const EXIT_STAGGER = 25;8const SLIDE_DURATION = 110;9const PRESS_DURATION = 126;10const HINT_DELAY = 260;11const HINT_DURATION = 120;12const ITEM_PITCH = 68;13const ITEM_HEIGHT = 54;1415const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));1617export function easeOutCubic(value) {18 const t = clamp(value);19 return 1 - ((1 - t) ** 3);20}2122export function easeInCubic(value) {23 return clamp(value) ** 3;24}2526export function easeOutBack(value) {27 const t = clamp(value);28 const overshoot = 1.70158;29 return 1 + (overshoot + 1) * ((t - 1) ** 3) + overshoot * ((t - 1) ** 2);30}3132function progress(now, start, duration) {33 if (!Number.isFinite(start)) return 1;34 return clamp((now - start) / duration);35}3637function staggerProgress(now, start, index, duration, stagger) {38 return progress(now, start + index * stagger, duration);39}4041/**42 * Derive all pause menu choreography from timestamps. This keeps render code43 * declarative and gives keyboard, pointer, and reduced-motion tests one pure44 * contract to inspect.45 */46export function pauseMotionFrame({47 now = 0,48 openedAt = 0,49 phase = 'idle',50 exitStartedAt = 0,51 itemCount = 0,52 selected = 0,53 previousSelected = selected,54 selectedAt = 0,55 pressedIndex = -1,56 pressedAt = 0,57 reducedMotion = false,58} = {}) {59 const openElapsed = Math.max(0, now - openedAt);60 const opening = !reducedMotion && phase === 'opening';61 const exiting = !reducedMotion && phase === 'exiting';62 const openProgress = reducedMotion ? 1 : progress(now, openedAt, OPEN_DURATION);63 const exitProgress = reducedMotion ? 1 : progress(now, exitStartedAt, EXIT_DURATION);64 const dimProgress = reducedMotion65 ? 166 : exiting67 ? 1 - easeInCubic(exitProgress)68 : easeOutCubic(progress(now, openedAt, 140));69 const titleProgress = reducedMotion ? 1 : progress(now, openedAt + 60, 160);70 const hintProgress = reducedMotion71 ? 172 : exiting73 ? 1 - easeInCubic(exitProgress)74 : easeOutCubic(progress(now, openedAt + HINT_DELAY, HINT_DURATION));75 const itemProgress = Array.from({ length: itemCount }, (_, index) => {76 if (reducedMotion) return 1;77 if (exiting) {78 const reverseIndex = itemCount - 1 - index;79 return 1 - easeInCubic(staggerProgress(now, exitStartedAt, reverseIndex, EXIT_ITEM_DURATION, EXIT_STAGGER));80 }81 return clamp(easeOutBack(staggerProgress(now, openedAt + 80, index, DROP_DURATION, DROP_STAGGER)));82 });83 const selectionProgress = reducedMotion || selected === previousSelected84 ? 185 : progress(now, selectedAt, SLIDE_DURATION);86 const slideProgress = reducedMotion || selected === previousSelected87 ? 188 : easeOutBack(selectionProgress);89 const selectedPosition = previousSelected + (selected - previousSelected) * slideProgress;90 const pawHop = reducedMotion ? 0 : Math.sin(Math.PI * selectionProgress) * 16;91 const pressProgress = pressedIndex < 0 || reducedMotion92 ? 093 : progress(now, pressedAt, PRESS_DURATION);94 const pressOffset = pressedIndex < 0 ? 0 : Math.sin(Math.PI * pressProgress) * 5;95 return {96 opening,97 exiting,98 done: phase === 'exiting' ? exitProgress >= 1 : openProgress >= 1,99 openProgress,100 exitProgress,101 dimProgress,102 shutterProgress: exiting ? 1 - easeInCubic(exitProgress) : easeOutBack(openProgress),103 titleProgress,104 hintProgress,105 itemProgress,106 selectedPosition,107 pawHop,108 pressProgress,109 pressOffset,110 active: phase !== 'idle' ||111 (now - selectedAt < SLIDE_DURATION) ||112 (pressedIndex >= 0 && now - pressedAt < PRESS_DURATION) ||113 (!reducedMotion && now - openedAt - HINT_DELAY < HINT_DURATION),114 };115}116117export const pauseMotionTimings = Object.freeze({118 open: OPEN_DURATION,119 exit: EXIT_DURATION,120 slide: SLIDE_DURATION,121 press: PRESS_DURATION,122 drop: DROP_DURATION,123 stagger: DROP_STAGGER,124});125126export const pauseMotionLayout = Object.freeze({ pitch: ITEM_PITCH, height: ITEM_HEIGHT });127128/** Match pointer targets to the settled tile rectangles drawn by stage-pause. */129export function pausePointerTarget({ x, y, width, height, itemCount, layout = pauseMotionLayout }) {130 if (x < width / 2 - 160 || x > width / 2 + 160) return "outside";131 const firstTop = height * 0.24 - layout.height / 2;132 const relativeY = y - firstTop;133 const index = Math.floor(relativeY / layout.pitch);134 if (index < 0 || index >= itemCount) return "outside";135 if (relativeY - index * layout.pitch >= layout.height) return null;136 return index;137}