UNPKG

joymap

Version:

A Gamepad API wrapper and mapping tool.

321 lines (276 loc) 8.89 kB
import { findKey, isEqual, map, assignIn, forEach, flow, flatten, uniq, uniqBy, toString, } from 'lodash/fp'; import { nameIsValid } from '../common/utils'; import { mockGamepad, getDefaultButtons, getDefaultSticks, updateListenOptions } from './baseUtils'; import { stopRumble, addRumble, applyRumble, getCurrentEffect, updateChannels, MAX_DURATION, } from './rumble'; import { ListenOptions, RawGamepad, Effect, BaseParams, CustomGamepad, StrictEffect, Button, Stick, } from '../types'; export type BaseModule = ReturnType<typeof createModule>; interface BaseState { threshold: number; clampThreshold: boolean; pad: CustomGamepad; prevPad: CustomGamepad; prevRumble: StrictEffect; lastRumbleUpdate: number; lastUpdate: number; buttons: Record<string, Button>; sticks: Record<string, Stick>; } export default function createModule(params: BaseParams = {}) { let listenOptions: ListenOptions | null = null; let gamepadId = params.padId ? params.padId : null; let connected = !!params.padId; const state: BaseState = { threshold: params.threshold || 0.2, clampThreshold: params.clampThreshold !== false, pad: mockGamepad, prevPad: mockGamepad, prevRumble: { duration: 0, weakMagnitude: 0, strongMagnitude: 0, }, lastRumbleUpdate: Date.now(), lastUpdate: Date.now(), buttons: getDefaultButtons(), sticks: getDefaultSticks(), }; const module = { getPadId: () => gamepadId, isConnected: () => connected, disconnect: () => { connected = false; }, connect: (padId?: string) => { connected = true; if (padId) { gamepadId = padId; } }, getConfig: () => JSON.stringify({ threshold: state.threshold, clampThreshold: state.clampThreshold, buttons: state.buttons, sticks: state.sticks, }), setConfig: (serializedString: string) => assignIn(state, JSON.parse(serializedString)), getButtonIndexes: (...inputNames: string[]) => flow( map((inputName: string) => state.buttons[inputName]), flatten, uniq, )(inputNames), getStickIndexes: (...inputNames: string[]) => flow( map((inputName: string) => state.sticks[inputName].indexes), flatten, uniqBy(toString), )(inputNames), setButton: (inputName: string, indexes: number[]) => { if (!nameIsValid(inputName)) { throw new Error(`On setButton('${inputName}'): argument contains invalid characters`); } state.buttons[inputName] = indexes; }, setStick: (inputName: string, indexes: number[][], inverts?: boolean[]) => { if (!nameIsValid(inputName)) { throw new Error(`On setStick('${inputName}'): inputName contains invalid characters`); } if (indexes.length === 0) { throw new Error(`On setStick('${inputName}', indexes): argument indexes is an empty array`); } state.sticks[inputName] = { indexes, inverts: inverts || map(() => false, indexes[0]), }; }, invertSticks: (inverts: boolean[], ...inputNames: string[]) => { forEach((inputName) => { const stick = state.sticks[inputName]; if (stick.inverts.length === inverts.length) { stick.inverts = inverts; } else { throw new Error( `On invertSticks(inverts, [..., ${inputName}, ...]): given argument inverts' length does not match '${inputName}' axis' length`, ); } }, inputNames); }, swapButtons: (btn1: string, btn2: string) => { const { buttons } = state; [buttons[btn1], buttons[btn2]] = [buttons[btn2], buttons[btn1]]; }, swapSticks: (stick1: string, stick2: string, includeInverts = false) => { const { sticks } = state; if (includeInverts) { [sticks[stick1], sticks[stick2]] = [sticks[stick2], sticks[stick1]]; } else { [sticks[stick1].indexes, sticks[stick2].indexes] = [ sticks[stick2].indexes, sticks[stick1].indexes, ]; } }, update: (gamepad: RawGamepad) => { state.prevPad = state.pad; state.pad = { axes: gamepad.axes as number[], buttons: map((a) => a.value, gamepad.buttons), rawPad: gamepad, }; if (listenOptions) { listenOptions = updateListenOptions(listenOptions, state.pad, state.threshold); } // Update rumble state if (module.isRumbleSupported()) { const now = Date.now(); const currentRumble = getCurrentEffect(gamepad.id); updateChannels(gamepad.id, now - state.lastUpdate); if ( state.prevRumble.weakMagnitude !== currentRumble.weakMagnitude || state.prevRumble.strongMagnitude !== currentRumble.strongMagnitude || now - state.lastRumbleUpdate >= MAX_DURATION / 2 ) { applyRumble(gamepad, currentRumble); state.prevRumble = currentRumble; state.lastRumbleUpdate = now; } state.lastUpdate = now; } }, cancelListen: () => { listenOptions = null; }, listenButton: ( callback: (indexes: number[]) => void, quantity = 1, { waitFor = [1, 'polls'], consecutive = false, allowOffset = true, }: { waitFor?: [number, 'polls' | 'ms']; consecutive?: boolean; allowOffset?: boolean } = {}, ) => { listenOptions = { callback: callback as (indexes: number[] | number[][]) => void, quantity, type: 'buttons', currentValue: 0, useTimeStamp: waitFor[1] === 'ms', targetValue: waitFor[0], consecutive, allowOffset, }; }, listenAxis: ( callback: (indexes: number[][]) => void, quantity = 2, { waitFor = [100, 'ms'], consecutive = true, allowOffset = true, }: { waitFor?: [number, 'polls' | 'ms']; consecutive?: boolean; allowOffset?: boolean } = {}, ) => { listenOptions = { callback: callback as (indexes: number[] | number[][]) => void, quantity, type: 'axes', currentValue: 0, useTimeStamp: waitFor[1] === 'ms', targetValue: waitFor[0], consecutive, allowOffset, }; }, buttonBindOnPress: ( inputName: string, callback: (buttonName?: string) => void, allowDuplication = false, ) => { if (!nameIsValid(inputName)) { throw new Error( `On buttonBindOnPress('${inputName}'): inputName contains invalid characters`, ); } module.listenButton((indexes: number[]) => { const resultName = findKey((value) => value[0] === indexes[0], state.buttons); if (!allowDuplication && resultName && state.buttons[inputName]) { module.swapButtons(inputName, resultName); } else { module.setButton(inputName, indexes); } callback(resultName); }); }, stickBindOnPress: ( inputName: string, callback: (stickName?: string) => void, allowDuplication = false, ) => { if (!nameIsValid(inputName)) { throw new Error( `On stickBindOnPress('${inputName}'): inputName contains invalid characters`, ); } module.listenAxis((indexesResult: number[][]) => { const resultName = findKey(({ indexes }) => isEqual(indexes, indexesResult), state.sticks); if (!allowDuplication && resultName && state.sticks[inputName]) { module.swapSticks(inputName, resultName); } else { module.setStick(inputName, indexesResult); } callback(resultName); }); }, isRumbleSupported: (rawPad?: RawGamepad) => { const padToTest = rawPad || state.pad.rawPad; if (padToTest) { return !!padToTest.vibrationActuator && !!padToTest.vibrationActuator.playEffect; } else { return null; } }, stopRumble: (channelName?: string) => { if (state.pad.rawPad) { stopRumble(state.pad.rawPad.id, channelName); } }, addRumble: (effect: Effect | Effect[], channelName?: string) => { if (state.pad.rawPad) { addRumble(state.pad.rawPad.id, effect, channelName); } }, destroy: () => { module.disconnect(); state.pad = mockGamepad; state.prevPad = mockGamepad; }, }; return { module, state }; }