clone

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

main
miau-is-true / src / input.js
input.js 14.0K
1/**2 * Input adapter for the stage.3 *4 * It translates physical input into small intent records and owns all repeat5 * timing. The stage is expected to consume intents immediately; this module6 * deliberately has no action queue. Call `tick(now)` from the stage loop, or7 * use `attach({ autoTick: true })` in a browser.8 */910export const INPUT_TIMING = Object.freeze({11 directionDelay: 175,12 directionRate: 110,13 undoDelay: 240,14 undoRateStart: 150,15 undoRateStep: 18,16 undoRateFloor: 55,17 stickDeadzone: 0.55,18 swipeThreshold: 26,19});2021const DIRECTIONS = Object.freeze({ up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } });22const KEY_DIRECTIONS = Object.freeze({23 ArrowUp: 'up', w: 'up', W: 'up',24 ArrowDown: 'down', s: 'down', S: 'down',25 ArrowLeft: 'left', a: 'left', A: 'left',26 ArrowRight: 'right', d: 'right', D: 'right',27});28const KEY_UNDO = new Set(['z', 'Z', 'u', 'U', 'Backspace']);2930function clockNow() {31 return typeof performance !== 'undefined' && Number.isFinite(performance.now()) ? performance.now() : Date.now();32}33function isFormTarget(target) {34 const tag = target?.tagName?.toUpperCase();35 return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable;36}37function directionFromVector(x, y, deadzone = INPUT_TIMING.stickDeadzone) {38 if (!Number.isFinite(x) || !Number.isFinite(y)) return null;39 if (Math.max(Math.abs(x), Math.abs(y)) < deadzone) return null;40 if (Math.abs(x) > Math.abs(y)) return x < 0 ? 'left' : 'right';41 return y < 0 ? 'up' : 'down';42}43function buttonPressed(button) { return Boolean(button && (typeof button === 'object' ? button.pressed : button)); }4445/**46 * Create the shared input adapter.47 *48 * `onIntent(intent)` receives `{ type, dir?, source?, point? }` records. The49 * `press`/`release`/`pointerDown`/`pointerUp` helpers are public so a canvas50 * stage and tests can feed the same adapter without synthesizing DOM events.51 */52export function createInput(options = {}) {53 const onIntent = typeof options.onIntent === 'function' ? options.onIntent : () => {};54 const now = typeof options.now === 'function' ? options.now : clockNow;55 const getGamepads = options.getGamepads || (() => globalThis.navigator?.getGamepads?.() || []);56 const target = options.target || globalThis.window;57 const timing = { ...INPUT_TIMING, ...(options.timing || {}) };58 const heldDirections = new Map();59 const heldUndo = new Map();60 let activeDirectionKey = null;61 let directionDue = null;62 let undoDue = null;63 let undoRepeats = 0;64 let gamepadDirection = null;65 let previousGamepadButtons = [];66 let touchStart = null;67 const activeTouches = new Map();68 let twoFingerSwipe = null;69 let attached = false;70 let frameHandle = null;7172 function emit(intent) {73 onIntent(Object.freeze({ ...intent }));74 }75 function emitMove(dir, source = 'keyboard') {76 if (!DIRECTIONS[dir]) return false;77 emit({ type: 'move', dir, vector: DIRECTIONS[dir], source });78 return true;79 }80 function latestDirectionKey() {81 let latest = null;82 for (const key of heldDirections.keys()) latest = key;83 return latest;84 }85 function scheduleDirection(key, time) {86 activeDirectionKey = key;87 directionDue = key === null ? null : time + timing.directionDelay;88 }89 function pressDirection(dir, source = 'keyboard', key = `${source}:${dir}`, time = now()) {90 if (!DIRECTIONS[dir] || heldDirections.has(key)) return false;91 heldDirections.set(key, { dir, source });92 scheduleDirection(key, time);93 emitMove(dir, source);94 return true;95 }96 function releaseDirection(key, time = now()) {97 if (!heldDirections.delete(key)) return false;98 if (activeDirectionKey === key) scheduleDirection(latestDirectionKey(), time);99 return true;100 }101 function pressUndo(source = 'keyboard', key = source, time = now()) {102 if (heldUndo.has(key)) return false;103 heldUndo.set(key, source);104 undoRepeats = 0;105 undoDue = time + timing.undoDelay;106 emit({ type: 'undo', source });107 return true;108 }109 function releaseUndo(key) {110 if (!heldUndo.delete(key)) return false;111 if (!heldUndo.size) { undoDue = null; undoRepeats = 0; }112 return true;113 }114 function press(dirOrIntent, source = 'keyboard', time = now()) {115 if (typeof dirOrIntent === 'string') {116 if (DIRECTIONS[dirOrIntent]) return pressDirection(dirOrIntent, source, `${source}:${dirOrIntent}`, time);117 if (dirOrIntent === 'undo') return pressUndo(source, source, time);118 if (dirOrIntent === 'world-previous' || dirOrIntent === 'world-next') {119 emit({ type: 'world', delta: dirOrIntent === 'world-next' ? 1 : -1, source });120 return true;121 }122 if (['confirm', 'restart', 'menu', 'mute', 'summary', 'wait'].includes(dirOrIntent)) { emit({ type: dirOrIntent, source }); return true; }123 return false;124 }125 if (!dirOrIntent || typeof dirOrIntent !== 'object') return false;126 return press(dirOrIntent.type === 'move' ? dirOrIntent.dir : dirOrIntent.type, dirOrIntent.source || source, time);127 }128 function release(dirOrIntent, source = 'keyboard', time = now()) {129 const value = typeof dirOrIntent === 'string' ? dirOrIntent : dirOrIntent?.dir || dirOrIntent?.type;130 if (DIRECTIONS[value]) return releaseDirection(`${source}:${value}`, time);131 if (value === 'undo') return releaseUndo(source);132 return false;133 }134 function pressAction(type, source = 'gamepad', time = now()) {135 if (type === 'undo') return pressUndo(source, source, time);136 if (type === 'world-previous' || type === 'world-next') return press(type, source, time);137 if (['confirm', 'restart', 'menu', 'mute', 'summary', 'wait'].includes(type)) { emit({ type, source }); return true; }138 return false;139 }140 function releaseAction(type, source = 'gamepad') {141 if (type === 'undo') return releaseUndo(source);142 return false;143 }144 function handleKeyDown(event) {145 if (!options.allowFormInput && isFormTarget(event.target)) return;146 const key = event.key;147 const dir = KEY_DIRECTIONS[key];148 if (dir) {149 event.preventDefault?.();150 if (!event.repeat) pressDirection(dir, 'keyboard', `keyboard:${key}`);151 return;152 }153 if (KEY_UNDO.has(key)) {154 event.preventDefault?.();155 if (!event.repeat) pressUndo('keyboard', `keyboard:${key}`);156 return;157 }158 const action = ({ Enter: 'confirm', ' ': 'wait', Escape: 'menu', p: 'menu', P: 'menu', r: 'restart', R: 'restart', m: 'mute', M: 'mute', t: 'summary', T: 'summary' })[key];159 if (action) { event.preventDefault?.(); if (!event.repeat) emit({ type: action, source: 'keyboard' }); }160 }161 function handleKeyUp(event) {162 const dir = KEY_DIRECTIONS[event.key];163 if (dir) releaseDirection(`keyboard:${event.key}`);164 if (KEY_UNDO.has(event.key)) releaseUndo(`keyboard:${event.key}`);165 }166 function handlePointerDown(event) {167 if (event.pointerType && event.pointerType !== 'touch' && event.pointerType !== 'pen') return;168 const point = { id: event.pointerId, x: event.clientX, y: event.clientY, time: now() };169 activeTouches.set(event.pointerId, point);170 if (activeTouches.size === 1) touchStart = point;171 if (activeTouches.size >= 2 && !twoFingerSwipe) {172 const points = [...activeTouches.values()].slice(0, 2);173 twoFingerSwipe = { points, current: new Map(points.map(point => [point.id, { ...point }])), emitted: false };174 }175 event.preventDefault?.();176 }177 function handlePointerUp(event) {178 if (twoFingerSwipe) {179 const current = twoFingerSwipe.current;180 current.set(event.pointerId, { ...current.get(event.pointerId), x: event.clientX, y: event.clientY });181 if (!twoFingerSwipe.emitted) {182 const [first, second] = twoFingerSwipe.points;183 const firstNow = current.get(first.id) || first;184 const secondNow = current.get(second.id) || second;185 const dx = ((firstNow.x - first.x) + (secondNow.x - second.x)) / 2;186 const dy = ((firstNow.y - first.y) + (secondNow.y - second.y)) / 2;187 if (Math.max(Math.abs(dx), Math.abs(dy)) >= timing.swipeThreshold && Math.abs(dx) >= Math.abs(dy)) {188 emit({ type: 'world', delta: dx < 0 ? -1 : 1, source: 'touch' });189 twoFingerSwipe.emitted = true;190 }191 }192 activeTouches.delete(event.pointerId);193 if (!activeTouches.size) { twoFingerSwipe = null; touchStart = null; }194 event.preventDefault?.();195 return;196 }197 if (!touchStart || touchStart.id !== event.pointerId) return;198 const start = touchStart; touchStart = null;199 activeTouches.delete(event.pointerId);200 const dx = event.clientX - start.x; const dy = event.clientY - start.y;201 if (Math.max(Math.abs(dx), Math.abs(dy)) >= timing.swipeThreshold) {202 const dir = directionFromVector(dx, dy, 0);203 emitMove(dir, 'touch');204 } else emit({ type: 'confirm', source: 'touch', point: { x: event.clientX, y: event.clientY } });205 event.preventDefault?.();206 }207 function handlePointerCancel(event) {208 activeTouches.delete(event.pointerId);209 if (!activeTouches.size) { touchStart = null; twoFingerSwipe = null; }210 }211 function bindThumbButtons(buttons = {}) {212 const cleanups = [];213 for (const [name, type] of [['undo', 'undo'], ['restart', 'restart'], ['menu', 'menu']]) {214 const button = buttons[name];215 if (!button?.addEventListener) continue;216 const down = event => { event.preventDefault?.(); pressAction(type, 'touch', now()); };217 const up = event => { event.preventDefault?.(); releaseAction(type, 'touch'); };218 const cancel = () => releaseAction(type, 'touch');219 button.addEventListener('pointerdown', down); button.addEventListener('pointerup', up); button.addEventListener('pointercancel', cancel);220 cleanups.push(() => { button.removeEventListener('pointerdown', down); button.removeEventListener('pointerup', up); button.removeEventListener('pointercancel', cancel); });221 }222 return () => cleanups.splice(0).forEach(cleanup => cleanup());223 }224 function pollGamepad(time) {225 const pad = [...(getGamepads?.() || [])].find(candidate => candidate?.connected !== false);226 if (!pad) {227 if (gamepadDirection) { releaseDirection('gamepad:direction', time); gamepadDirection = null; }228 previousGamepadButtons = []; return;229 }230 const dpad = buttonPressed(pad.buttons?.[12]) ? 'up' : buttonPressed(pad.buttons?.[13]) ? 'down' : buttonPressed(pad.buttons?.[14]) ? 'left' : buttonPressed(pad.buttons?.[15]) ? 'right' : null;231 const stick = directionFromVector(pad.axes?.[0], pad.axes?.[1], timing.stickDeadzone);232 const dir = dpad || stick;233 if (dir !== gamepadDirection) {234 if (gamepadDirection) releaseDirection('gamepad:direction', time);235 if (dir) { pressDirection(dir, 'gamepad', 'gamepad:direction', time); gamepadDirection = dir; }236 else gamepadDirection = null;237 }238 const actionButtons = [[0, 'confirm'], [1, 'undo'], [2, 'wait'], [3, 'restart'], [4, 'world-previous'], [5, 'world-next'], [9, 'menu']];239 for (const [index, action] of actionButtons) {240 const current = buttonPressed(pad.buttons?.[index]); const previous = Boolean(previousGamepadButtons[index]);241 if (current && !previous) pressAction(action, 'gamepad', time);242 if (!current && previous) releaseAction(action, 'gamepad');243 previousGamepadButtons[index] = current;244 }245 }246 function tick(time = now()) {247 pollGamepad(time);248 if (activeDirectionKey && directionDue !== null && time >= directionDue) {249 const held = heldDirections.get(activeDirectionKey);250 if (held) { emitMove(held.dir, held.source); directionDue = time + timing.directionRate; }251 else scheduleDirection(latestDirectionKey(), time);252 }253 if (undoDue !== null && heldUndo.size && time >= undoDue) {254 const source = heldUndo.values().next().value;255 emit({ type: 'undo', source });256 undoRepeats += 1;257 const gap = Math.max(timing.undoRateFloor, timing.undoRateStart - timing.undoRateStep * (undoRepeats - 1));258 undoDue = time + gap;259 }260 }261 function frame(time) {262 tick(time);263 if (attached && options.autoTick !== false && typeof target?.requestAnimationFrame === 'function') frameHandle = target.requestAnimationFrame(frame);264 }265 function attach(attachOptions = {}) {266 if (attached) return api;267 attached = true;268 const eventTarget = attachOptions.target || target;269 eventTarget?.addEventListener?.('keydown', handleKeyDown);270 eventTarget?.addEventListener?.('keyup', handleKeyUp);271 const pointerTarget = attachOptions.pointerTarget || attachOptions.stage;272 pointerTarget?.addEventListener?.('pointerdown', handlePointerDown);273 pointerTarget?.addEventListener?.('pointerup', handlePointerUp);274 pointerTarget?.addEventListener?.('pointercancel', handlePointerCancel);275 if (options.autoTick !== false && typeof eventTarget?.requestAnimationFrame === 'function') frameHandle = eventTarget.requestAnimationFrame(frame);276 return api;277 }278 function detach(detachOptions = {}) {279 const eventTarget = detachOptions.target || target;280 eventTarget?.removeEventListener?.('keydown', handleKeyDown); eventTarget?.removeEventListener?.('keyup', handleKeyUp);281 const pointerTarget = detachOptions.pointerTarget || detachOptions.stage;282 pointerTarget?.removeEventListener?.('pointerdown', handlePointerDown); pointerTarget?.removeEventListener?.('pointerup', handlePointerUp); pointerTarget?.removeEventListener?.('pointercancel', handlePointerCancel);283 if (frameHandle !== null && typeof eventTarget?.cancelAnimationFrame === 'function') eventTarget.cancelAnimationFrame(frameHandle);284 frameHandle = null; attached = false;285 return api;286 }287 function reset() {288 heldDirections.clear(); heldUndo.clear(); activeDirectionKey = null; directionDue = null; undoDue = null; undoRepeats = 0; gamepadDirection = null; previousGamepadButtons = []; touchStart = null; activeTouches.clear(); twoFingerSwipe = null;289 }290 const api = Object.freeze({ attach, detach, destroy: detach, reset, tick, press, release, pressAction, releaseAction, pointerDown: handlePointerDown, pointerUp: handlePointerUp, pointerCancel: handlePointerCancel, bindThumbButtons, get attached() { return attached; }, timing });291 return api;292}293294export default createInput;