1/** Shared, deterministic presentation math for the in-play HUD. */2export const HUD_MOTION = Object.freeze({3 keycapPress: 90,4 stepRoll: 120,5});67export function easeOutCubic(progress) {8 const t = Math.max(0, Math.min(1, Number(progress) || 0));9 return 1 - (1 - t) ** 3;10}1112export function motionProgress({ now = 0, startedAt = 0, duration, reducedMotion = false } = {}) {13 if (reducedMotion) return 1;14 if (!Number.isFinite(now) || !Number.isFinite(startedAt) || !Number.isFinite(duration) || duration <= 0)15 return 1;16 return Math.max(0, Math.min(1, (now - startedAt) / duration));17}1819export function keycapPressFrame({ now, startedAt, duration = HUD_MOTION.keycapPress, reducedMotion = false } = {}) {20 const progress = motionProgress({ now, startedAt, duration, reducedMotion });21 // The press is a short settle onto the lip, then a slightly longer spring back.22 const press = progress < 0.4523 ? easeOutCubic(progress / 0.45)24 : 1 - easeOutCubic((progress - 0.45) / 0.55);25 return {26 progress,27 press: reducedMotion ? 0 : Math.max(0, Math.min(1, press)),28 offsetY: reducedMotion ? 0 : 4 * Math.max(0, Math.min(1, press)),29 pressed: !reducedMotion && progress < 1,30 };31}3233const ACTION_ALIASES = Object.freeze({34 wait: ['wait', 'space'],35 undo: ['undo', 'z'],36 restart: ['restart', 'r'],37 menu: ['menu', 'esc', 'escape'],38 confirm: ['confirm', 'enter', 'select'],39 workshop: ['workshop', 'space'],40 details: ['details', 'i'],41 copy: ['copy', 'c'],42 navigate: ['navigate', 'up', 'down', 'left', 'right', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'move-up', 'move-down', 'move-left', 'move-right'],43 'move-left': ['move-left', 'left', 'arrowleft'],44 'move-up': ['move-up', 'up', 'arrowup'],45 'move-right': ['move-right', 'right', 'arrowright'],46 'move-down': ['move-down', 'down', 'arrowdown'],47});4849function actionToken(value) {50 if (value && typeof value === 'object') return actionToken(value.action || value.name || value.key);51 return String(value || '').trim().toLowerCase().replace(/[ _]/g, '-');52}5354export function actionMatches(binding, action) {55 const left = actionToken(binding);56 const right = actionToken(action);57 if (!left || !right) return false;58 const aliases = ACTION_ALIASES[left] || [left];59 return aliases.includes(right) || (ACTION_ALIASES[right] || [right]).includes(left);60}6162export function stepRollFrame({ now, startedAt, from = 0, to = from, duration = HUD_MOTION.stepRoll, reducedMotion = false } = {}) {63 const oldValue = Math.max(0, Number.isFinite(Number(from)) ? Math.trunc(Number(from)) : 0);64 const newValue = Math.max(0, Number.isFinite(Number(to)) ? Math.trunc(Number(to)) : oldValue);65 const progress = motionProgress({ now, startedAt, duration, reducedMotion });66 const direction = newValue >= oldValue ? 1 : -1;67 const oldDigits = String(oldValue).padStart(2, '0');68 const newDigits = String(newValue).padStart(2, '0');69 return {70 progress,71 eased: easeOutCubic(progress),72 direction,73 oldValue,74 newValue,75 oldDigits,76 newDigits,77 changed: [...newDigits].map((digit, index) => digit !== oldDigits[index]),78 };79}