- commit
- 1e756ac9437ce14c7d0566da357e981f6b0513b0
- parent
- 646db9b0ecabb280d79d87c65066f7e764859299
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 258 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 95 | +++++++++++ |
@@ -0,0 +1,258 @@
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+ */9+10+export 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+});20+21+const DIRECTIONS = Object.freeze({ up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } });22+const 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+});28+const KEY_UNDO = new Set(['z', 'Z', 'u', 'U', 'Backspace']);29+30+function clockNow() {31+ return typeof performance !== 'undefined' && Number.isFinite(performance.now()) ? performance.now() : Date.now();32+}33+function isFormTarget(target) {34+ const tag = target?.tagName?.toUpperCase();35+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable;36+}37+function 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+}43+function buttonPressed(button) { return Boolean(button && (typeof button === 'object' ? button.pressed : button)); }44+45+/**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+ */52+export 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+ let attached = false;68+ let frameHandle = null;69+70+ function emit(intent) {71+ onIntent(Object.freeze({ ...intent }));72+ }73+ function emitMove(dir, source = 'keyboard') {74+ if (!DIRECTIONS[dir]) return false;75+ emit({ type: 'move', dir, vector: DIRECTIONS[dir], source });76+ return true;77+ }78+ function latestDirectionKey() {79+ let latest = null;80+ for (const key of heldDirections.keys()) latest = key;81+ return latest;82+ }83+ function scheduleDirection(key, time) {84+ activeDirectionKey = key;85+ directionDue = key === null ? null : time + timing.directionDelay;86+ }87+ function pressDirection(dir, source = 'keyboard', key = `${source}:${dir}`, time = now()) {88+ if (!DIRECTIONS[dir] || heldDirections.has(key)) return false;89+ heldDirections.set(key, { dir, source });90+ scheduleDirection(key, time);91+ emitMove(dir, source);92+ return true;93+ }94+ function releaseDirection(key, time = now()) {95+ if (!heldDirections.delete(key)) return false;96+ if (activeDirectionKey === key) scheduleDirection(latestDirectionKey(), time);97+ return true;98+ }99+ function pressUndo(source = 'keyboard', key = source, time = now()) {100+ if (heldUndo.has(key)) return false;101+ heldUndo.set(key, source);102+ undoRepeats = 0;103+ undoDue = time + timing.undoDelay;104+ emit({ type: 'undo', source });105+ return true;106+ }107+ function releaseUndo(key) {108+ if (!heldUndo.delete(key)) return false;109+ if (!heldUndo.size) { undoDue = null; undoRepeats = 0; }110+ return true;111+ }112+ function press(dirOrIntent, source = 'keyboard', time = now()) {113+ if (typeof dirOrIntent === 'string') {114+ if (DIRECTIONS[dirOrIntent]) return pressDirection(dirOrIntent, source, `${source}:${dirOrIntent}`, time);115+ if (dirOrIntent === 'undo') return pressUndo(source, source, time);116+ if (['confirm', 'restart', 'menu', 'mute'].includes(dirOrIntent)) { emit({ type: dirOrIntent, source }); return true; }117+ return false;118+ }119+ if (!dirOrIntent || typeof dirOrIntent !== 'object') return false;120+ return press(dirOrIntent.type === 'move' ? dirOrIntent.dir : dirOrIntent.type, dirOrIntent.source || source, time);121+ }122+ function release(dirOrIntent, source = 'keyboard', time = now()) {123+ const value = typeof dirOrIntent === 'string' ? dirOrIntent : dirOrIntent?.dir || dirOrIntent?.type;124+ if (DIRECTIONS[value]) return releaseDirection(`${source}:${value}`, time);125+ if (value === 'undo') return releaseUndo(source);126+ return false;127+ }128+ function pressAction(type, source = 'gamepad', time = now()) {129+ if (type === 'undo') return pressUndo(source, source, time);130+ if (['confirm', 'restart', 'menu', 'mute'].includes(type)) { emit({ type, source }); return true; }131+ return false;132+ }133+ function releaseAction(type, source = 'gamepad') {134+ if (type === 'undo') return releaseUndo(source);135+ return false;136+ }137+ function handleKeyDown(event) {138+ if (!options.allowFormInput && isFormTarget(event.target)) return;139+ const key = event.key;140+ const dir = KEY_DIRECTIONS[key];141+ if (dir) {142+ event.preventDefault?.();143+ if (!event.repeat) pressDirection(dir, 'keyboard', `keyboard:${key}`);144+ return;145+ }146+ if (KEY_UNDO.has(key)) {147+ event.preventDefault?.();148+ if (!event.repeat) pressUndo('keyboard', `keyboard:${key}`);149+ return;150+ }151+ const action = ({ Enter: 'confirm', Escape: 'menu', p: 'menu', P: 'menu', r: 'restart', R: 'restart', m: 'mute', M: 'mute' })[key];152+ if (action) { event.preventDefault?.(); if (!event.repeat) emit({ type: action, source: 'keyboard' }); }153+ }154+ function handleKeyUp(event) {155+ const dir = KEY_DIRECTIONS[event.key];156+ if (dir) releaseDirection(`keyboard:${event.key}`);157+ if (KEY_UNDO.has(event.key)) releaseUndo(`keyboard:${event.key}`);158+ }159+ function handlePointerDown(event) {160+ if (event.pointerType && event.pointerType !== 'touch' && event.pointerType !== 'pen') return;161+ touchStart = { id: event.pointerId, x: event.clientX, y: event.clientY, time: now() };162+ event.preventDefault?.();163+ }164+ function handlePointerUp(event) {165+ if (!touchStart || touchStart.id !== event.pointerId) return;166+ const start = touchStart; touchStart = null;167+ const dx = event.clientX - start.x; const dy = event.clientY - start.y;168+ if (Math.max(Math.abs(dx), Math.abs(dy)) >= timing.swipeThreshold) {169+ const dir = directionFromVector(dx, dy, 0);170+ emitMove(dir, 'touch');171+ } else emit({ type: 'confirm', source: 'touch', point: { x: event.clientX, y: event.clientY } });172+ event.preventDefault?.();173+ }174+ function handlePointerCancel(event) { if (!touchStart || touchStart.id === event.pointerId) touchStart = null; }175+ function bindThumbButtons(buttons = {}) {176+ const cleanups = [];177+ for (const [name, type] of [['undo', 'undo'], ['restart', 'restart'], ['menu', 'menu']]) {178+ const button = buttons[name];179+ if (!button?.addEventListener) continue;180+ const down = event => { event.preventDefault?.(); pressAction(type, 'touch', now()); };181+ const up = event => { event.preventDefault?.(); releaseAction(type, 'touch'); };182+ const cancel = () => releaseAction(type, 'touch');183+ button.addEventListener('pointerdown', down); button.addEventListener('pointerup', up); button.addEventListener('pointercancel', cancel);184+ cleanups.push(() => { button.removeEventListener('pointerdown', down); button.removeEventListener('pointerup', up); button.removeEventListener('pointercancel', cancel); });185+ }186+ return () => cleanups.splice(0).forEach(cleanup => cleanup());187+ }188+ function pollGamepad(time) {189+ const pad = [...(getGamepads?.() || [])].find(candidate => candidate?.connected !== false);190+ if (!pad) {191+ if (gamepadDirection) { releaseDirection('gamepad:direction', time); gamepadDirection = null; }192+ previousGamepadButtons = []; return;193+ }194+ const dpad = buttonPressed(pad.buttons?.[12]) ? 'up' : buttonPressed(pad.buttons?.[13]) ? 'down' : buttonPressed(pad.buttons?.[14]) ? 'left' : buttonPressed(pad.buttons?.[15]) ? 'right' : null;195+ const stick = directionFromVector(pad.axes?.[0], pad.axes?.[1], timing.stickDeadzone);196+ const dir = dpad || stick;197+ if (dir !== gamepadDirection) {198+ if (gamepadDirection) releaseDirection('gamepad:direction', time);199+ if (dir) { pressDirection(dir, 'gamepad', 'gamepad:direction', time); gamepadDirection = dir; }200+ else gamepadDirection = null;201+ }202+ const actionButtons = [[0, 'confirm'], [1, 'undo'], [3, 'restart'], [9, 'menu']];203+ for (const [index, action] of actionButtons) {204+ const current = buttonPressed(pad.buttons?.[index]); const previous = Boolean(previousGamepadButtons[index]);205+ if (current && !previous) pressAction(action, 'gamepad', time);206+ if (!current && previous) releaseAction(action, 'gamepad');207+ previousGamepadButtons[index] = current;208+ }209+ }210+ function tick(time = now()) {211+ pollGamepad(time);212+ if (activeDirectionKey && directionDue !== null && time >= directionDue) {213+ const held = heldDirections.get(activeDirectionKey);214+ if (held) { emitMove(held.dir, held.source); directionDue = time + timing.directionRate; }215+ else scheduleDirection(latestDirectionKey(), time);216+ }217+ if (undoDue !== null && heldUndo.size && time >= undoDue) {218+ const source = heldUndo.values().next().value;219+ emit({ type: 'undo', source });220+ undoRepeats += 1;221+ const gap = Math.max(timing.undoRateFloor, timing.undoRateStart - timing.undoRateStep * (undoRepeats - 1));222+ undoDue = time + gap;223+ }224+ }225+ function frame(time) {226+ tick(time);227+ if (attached && options.autoTick !== false && typeof target?.requestAnimationFrame === 'function') frameHandle = target.requestAnimationFrame(frame);228+ }229+ function attach(attachOptions = {}) {230+ if (attached) return api;231+ attached = true;232+ const eventTarget = attachOptions.target || target;233+ eventTarget?.addEventListener?.('keydown', handleKeyDown);234+ eventTarget?.addEventListener?.('keyup', handleKeyUp);235+ const pointerTarget = attachOptions.pointerTarget || attachOptions.stage;236+ pointerTarget?.addEventListener?.('pointerdown', handlePointerDown);237+ pointerTarget?.addEventListener?.('pointerup', handlePointerUp);238+ pointerTarget?.addEventListener?.('pointercancel', handlePointerCancel);239+ if (options.autoTick !== false && typeof eventTarget?.requestAnimationFrame === 'function') frameHandle = eventTarget.requestAnimationFrame(frame);240+ return api;241+ }242+ function detach(detachOptions = {}) {243+ const eventTarget = detachOptions.target || target;244+ eventTarget?.removeEventListener?.('keydown', handleKeyDown); eventTarget?.removeEventListener?.('keyup', handleKeyUp);245+ const pointerTarget = detachOptions.pointerTarget || detachOptions.stage;246+ pointerTarget?.removeEventListener?.('pointerdown', handlePointerDown); pointerTarget?.removeEventListener?.('pointerup', handlePointerUp); pointerTarget?.removeEventListener?.('pointercancel', handlePointerCancel);247+ if (frameHandle !== null && typeof eventTarget?.cancelAnimationFrame === 'function') eventTarget.cancelAnimationFrame(frameHandle);248+ frameHandle = null; attached = false;249+ return api;250+ }251+ function reset() {252+ heldDirections.clear(); heldUndo.clear(); activeDirectionKey = null; directionDue = null; undoDue = null; undoRepeats = 0; gamepadDirection = null; previousGamepadButtons = []; touchStart = null;253+ }254+ 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 });255+ return api;256+}257+258+export default createInput;@@ -0,0 +1,95 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createInput } from '../src/input.js';4+5+class FakeTarget {6+ constructor() { this.listeners = new Map(); }7+ addEventListener(type, handler) { this.listeners.set(`${type}:${handler.name}:${this.listeners.size}`, handler); }8+ removeEventListener(type, handler) {9+ for (const [key, value] of this.listeners) if (key.startsWith(`${type}:`) && value === handler) this.listeners.delete(key);10+ }11+ dispatch(type, event = {}) {12+ for (const [key, handler] of this.listeners) if (key.startsWith(`${type}:`)) handler({ preventDefault() {}, target: {}, ...event });13+ }14+}15+16+function harness() {17+ const target = new FakeTarget();18+ const intents = [];19+ let time = 0;20+ const input = createInput({ target, autoTick: false, now: () => time, getGamepads: () => [], onIntent: intent => intents.push({ ...intent }) });21+ input.attach();22+ return { target, input, intents, setTime(value) { time = value; }, tick(value) { time = value; input.tick(value); } };23+}24+25+test('keyboard presses act immediately and browser repeat is ignored', () => {26+ const h = harness();27+ h.target.dispatch('keydown', { key: 'ArrowRight', repeat: false });28+ h.target.dispatch('keydown', { key: 'ArrowRight', repeat: true });29+ assert.deepEqual(h.intents.map(intent => intent.type), ['move']);30+ assert.equal(h.intents[0].dir, 'right');31+ h.tick(174); assert.equal(h.intents.length, 1);32+ h.tick(175); assert.equal(h.intents.length, 2);33+ h.target.dispatch('keyup', { key: 'ArrowRight' });34+ h.tick(500); assert.equal(h.intents.length, 2);35+});36+37+test('only the most recent held direction repeats, then older direction resumes', () => {38+ const h = harness();39+ h.target.dispatch('keydown', { key: 'ArrowLeft', repeat: false });40+ h.setTime(10); h.target.dispatch('keydown', { key: 'ArrowDown', repeat: false });41+ h.tick(184); assert.deepEqual(h.intents.map(intent => intent.dir), ['left', 'down']);42+ h.tick(185); assert.deepEqual(h.intents.map(intent => intent.dir), ['left', 'down', 'down']);43+ h.setTime(200); h.target.dispatch('keyup', { key: 'ArrowDown' });44+ h.tick(374); assert.equal(h.intents.length, 3);45+ h.tick(375); assert.deepEqual(h.intents.map(intent => intent.dir), ['left', 'down', 'down', 'left']);46+});47+48+test('undo repeats after its delay and accelerates to the configured floor', () => {49+ const h = harness();50+ h.target.dispatch('keydown', { key: 'z', repeat: false });51+ h.tick(239); assert.equal(h.intents.length, 1);52+ h.tick(240); assert.equal(h.intents.length, 2);53+ h.tick(389); assert.equal(h.intents.length, 2);54+ h.tick(390); assert.equal(h.intents.length, 3);55+ h.tick(521); assert.equal(h.intents.length, 3);56+ h.tick(522); assert.equal(h.intents.length, 4);57+ h.tick(800); assert.equal(h.intents.length, 5);58+ h.target.dispatch('keyup', { key: 'z' });59+ h.tick(2_000); assert.equal(h.intents.length, 5);60+ assert.ok(h.intents.every(intent => intent.type === 'undo'));61+});62+63+test('standard gamepad d-pad, stick, and action buttons share the input contract', () => {64+ const intents = [];65+ let time = 0;66+ const buttons = Array.from({ length: 16 }, () => ({ pressed: false }));67+ const pad = { connected: true, buttons, axes: [0, 0] };68+ const input = createInput({ autoTick: false, now: () => time, getGamepads: () => [pad], onIntent: intent => intents.push({ ...intent }) });69+ buttons[12].pressed = true; buttons[0].pressed = true;70+ input.tick(0);71+ assert.deepEqual(intents.map(intent => intent.type), ['move', 'confirm']);72+ assert.equal(intents[0].dir, 'up');73+ buttons[12].pressed = false; buttons[0].pressed = false; pad.axes[0] = 0.8;74+ time = 1; input.tick(time);75+ assert.equal(intents.at(-1).dir, 'right');76+ buttons[1].pressed = true; time = 2; input.tick(time);77+ assert.equal(intents.at(-1).type, 'undo');78+ buttons[1].pressed = false; pad.axes[0] = 0; time = 3; input.tick(time);79+ time = 242; input.tick(time);80+ assert.equal(intents.at(-1).type, 'undo');81+});82+83+test('touch swipes move while taps confirm', () => {84+ const h = harness();85+ h.input.pointerDown({ pointerId: 1, pointerType: 'touch', clientX: 10, clientY: 20, preventDefault() {} });86+ h.input.pointerUp({ pointerId: 1, pointerType: 'touch', clientX: 20, clientY: 25, preventDefault() {} });87+ assert.equal(h.intents.at(-1).type, 'confirm');88+ h.input.pointerDown({ pointerId: 2, pointerType: 'touch', clientX: 10, clientY: 20, preventDefault() {} });89+ h.input.pointerUp({ pointerId: 2, pointerType: 'touch', clientX: 42, clientY: 21, preventDefault() {} });90+ assert.equal(h.intents.at(-1).type, 'move');91+ assert.equal(h.intents.at(-1).dir, 'right');92+ h.input.pointerDown({ pointerId: 3, pointerType: 'touch', clientX: 30, clientY: 40, preventDefault() {} });93+ h.input.pointerUp({ pointerId: 3, pointerType: 'touch', clientX: 30, clientY: 67, preventDefault() {} });94+ assert.equal(h.intents.at(-1).dir, 'down');95+});