gamepad-mapper.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. /**
  2. * GamepadMapper - A comprehensive gamepad mapping and input handling system
  3. * Addresses shortcomings of the vanilla Gamepad API:
  4. * - Stick drift compensation with configurable deadzones
  5. * - Button debouncing to prevent multiple rapid presses
  6. * - Standardized controller mapping across different brands
  7. * - Event-driven API instead of polling
  8. */
  9. export class GamepadMapper {
  10. constructor(options = {}) {
  11. // Configuration
  12. this.config = {
  13. deadzone: options.deadzone || 0.1,
  14. debounceTime: options.debounceTime || 50, // ms
  15. pollRate: options.pollRate || 60, // fps
  16. enableVibration: options.enableVibration || false,
  17. ...options
  18. };
  19. // State tracking
  20. this.connectedGamepads = new Map();
  21. this.previousStates = new Map();
  22. this.buttonDebounceTimers = new Map();
  23. this.eventListeners = new Map();
  24. this.isPolling = false;
  25. // Standard controller mappings
  26. this.controllerMappings = this.initializeControllerMappings();
  27. this.init();
  28. }
  29. init() {
  30. // Set up gamepad connection events
  31. window.addEventListener('gamepadconnected', this.handleGamepadConnected.bind(this));
  32. window.addEventListener('gamepaddisconnected', this.handleGamepadDisconnected.bind(this));
  33. // Check for already connected gamepads
  34. this.scanForGamepads();
  35. // Start polling loop
  36. this.startPolling();
  37. }
  38. initializeControllerMappings() {
  39. return {
  40. // Standard mapping (most Xbox-compatible controllers)
  41. 'standard': {
  42. buttons: {
  43. 0: 'A', // Bottom face button
  44. 1: 'B', // Right face button
  45. 2: 'X', // Left face button
  46. 3: 'Y', // Top face button
  47. 4: 'LB', // Left bumper
  48. 5: 'RB', // Right bumper
  49. 6: 'LT', // Left trigger (digital)
  50. 7: 'RT', // Right trigger (digital)
  51. 8: 'SELECT', // Select/Back button
  52. 9: 'START', // Start/Menu button
  53. 10: 'LS', // Left stick press
  54. 11: 'RS', // Right stick press
  55. 12: 'DPAD_UP',
  56. 13: 'DPAD_DOWN',
  57. 14: 'DPAD_LEFT',
  58. 15: 'DPAD_RIGHT',
  59. 16: 'HOME' // Home/Guide button (if available)
  60. },
  61. axes: {
  62. 0: 'LEFT_STICK_X',
  63. 1: 'LEFT_STICK_Y',
  64. 2: 'RIGHT_STICK_X',
  65. 3: 'RIGHT_STICK_Y',
  66. // Some controllers have analog triggers on axes 2/3 instead
  67. // We'll detect this dynamically
  68. }
  69. },
  70. // PlayStation controllers (DS4, DualSense)
  71. 'playstation': {
  72. buttons: {
  73. 0: 'X', // Cross (bottom)
  74. 1: 'CIRCLE', // Circle (right)
  75. 2: 'SQUARE', // Square (left)
  76. 3: 'TRIANGLE', // Triangle (top)
  77. 4: 'L1', // Left bumper
  78. 5: 'R1', // Right bumper
  79. 6: 'L2', // Left trigger (digital)
  80. 7: 'R2', // Right trigger (digital)
  81. 8: 'SHARE', // Share button
  82. 9: 'OPTIONS', // Options button
  83. 10: 'L3', // Left stick press
  84. 11: 'R3', // Right stick press
  85. 12: 'DPAD_UP',
  86. 13: 'DPAD_DOWN',
  87. 14: 'DPAD_LEFT',
  88. 15: 'DPAD_RIGHT',
  89. 16: 'PS' // PlayStation button
  90. },
  91. axes: {
  92. 0: 'LEFT_STICK_X',
  93. 1: 'LEFT_STICK_Y',
  94. 2: 'RIGHT_STICK_X',
  95. 3: 'RIGHT_STICK_Y'
  96. }
  97. },
  98. // Nintendo Switch Pro Controller
  99. 'nintendo': {
  100. buttons: {
  101. 0: 'B', // B (bottom)
  102. 1: 'A', // A (right)
  103. 2: 'Y', // Y (left)
  104. 3: 'X', // X (top)
  105. 4: 'L', // Left bumper
  106. 5: 'R', // Right bumper
  107. 6: 'ZL', // Left trigger
  108. 7: 'ZR', // Right trigger
  109. 8: 'MINUS', // Minus button
  110. 9: 'PLUS', // Plus button
  111. 10: 'LS', // Left stick press
  112. 11: 'RS', // Right stick press
  113. 12: 'HOME', // Home button
  114. 13: 'SCREENSHOT' // Screenshot button
  115. },
  116. axes: {
  117. 0: 'DPAD_AXIS', // Nintendo DPAD on axis 0 (interferes with LEFT_STICK_Y)
  118. 1: 'LEFT_STICK_X', // Nintendo left stick X on axis 1
  119. 2: 'LEFT_STICK_Y', // Nintendo left stick Y on axis 2 (was showing as RIGHT_STICK_X)
  120. 3: 'RIGHT_STICK_X', // Nintendo right stick X on axis 3 (was showing as RIGHT_STICK_Y)
  121. 4: 'RIGHT_STICK_Y' // Nintendo right stick Y on axis 4 (Hori controller)
  122. }
  123. }
  124. };
  125. }
  126. detectControllerType(gamepad) {
  127. const id = gamepad.id.toLowerCase();
  128. // PlayStation controllers
  129. if (id.includes('playstation') || id.includes('dualshock') || id.includes('dualsense') ||
  130. id.includes('054c')) { // Sony vendor ID
  131. return 'playstation';
  132. }
  133. // Nintendo controllers
  134. if (id.includes('nintendo') || id.includes('pro controller') || id.includes('057e')) { // Nintendo vendor ID
  135. return 'nintendo';
  136. }
  137. // Xbox and most other controllers use standard mapping
  138. return 'standard';
  139. }
  140. handleGamepadConnected(event) {
  141. const gamepad = event.gamepad;
  142. const controllerType = this.detectControllerType(gamepad);
  143. console.log(`🎮 Gamepad connected: ${gamepad.id} (Type: ${controllerType})`);
  144. this.connectedGamepads.set(gamepad.index, {
  145. gamepad,
  146. type: controllerType,
  147. mapping: this.controllerMappings[controllerType],
  148. lastUpdate: 0
  149. });
  150. this.previousStates.set(gamepad.index, {
  151. buttons: {},
  152. axes: {}
  153. });
  154. this.emit('connected', {
  155. index: gamepad.index,
  156. id: gamepad.id,
  157. type: controllerType
  158. });
  159. }
  160. handleGamepadDisconnected(event) {
  161. const index = event.gamepad.index;
  162. console.log(`🎮 Gamepad disconnected: ${event.gamepad.id}`);
  163. this.connectedGamepads.delete(index);
  164. this.previousStates.delete(index);
  165. // Clear any pending debounce timers
  166. if (this.buttonDebounceTimers.has(index)) {
  167. const timers = this.buttonDebounceTimers.get(index);
  168. Object.values(timers).forEach(timer => clearTimeout(timer));
  169. this.buttonDebounceTimers.delete(index);
  170. }
  171. this.emit('disconnected', {
  172. index: index,
  173. id: event.gamepad.id
  174. });
  175. }
  176. scanForGamepads() {
  177. const gamepads = navigator.getGamepads();
  178. for (let i = 0; i < gamepads.length; i++) {
  179. if (gamepads[i] && !this.connectedGamepads.has(i)) {
  180. // Simulate connection event for already connected gamepads
  181. this.handleGamepadConnected({ gamepad: gamepads[i] });
  182. }
  183. }
  184. }
  185. startPolling() {
  186. if (this.isPolling) return;
  187. this.isPolling = true;
  188. const poll = () => {
  189. if (!this.isPolling) return;
  190. this.updateGamepadStates();
  191. setTimeout(() => requestAnimationFrame(poll), 1000 / this.config.pollRate);
  192. };
  193. requestAnimationFrame(poll);
  194. }
  195. stopPolling() {
  196. this.isPolling = false;
  197. }
  198. updateGamepadStates() {
  199. const gamepads = navigator.getGamepads();
  200. for (const [index, controllerData] of this.connectedGamepads) {
  201. const gamepad = gamepads[index];
  202. if (!gamepad) continue;
  203. const previousState = this.previousStates.get(index);
  204. const currentState = {
  205. buttons: {},
  206. axes: {}
  207. };
  208. // Process buttons
  209. this.processButtons(gamepad, controllerData, previousState, currentState);
  210. // Process axes (analog sticks, triggers)
  211. this.processAxes(gamepad, controllerData, previousState, currentState);
  212. // Update previous state
  213. this.previousStates.set(index, currentState);
  214. }
  215. }
  216. processButtons(gamepad, controllerData, previousState, currentState) {
  217. const mapping = controllerData.mapping.buttons;
  218. gamepad.buttons.forEach((button, buttonIndex) => {
  219. const buttonName = mapping[buttonIndex];
  220. if (!buttonName) return;
  221. const isPressed = button.pressed;
  222. const wasPressed = previousState.buttons[buttonName] || false;
  223. currentState.buttons[buttonName] = isPressed;
  224. // Handle analog triggers as axes if they have analog values
  225. if ((buttonName === 'LT' || buttonName === 'RT' || buttonName === 'L2' || buttonName === 'R2') && button.value > 0) {
  226. const axisName = buttonName === 'LT' || buttonName === 'L2' ? 'LEFT_TRIGGER' : 'RIGHT_TRIGGER';
  227. const previousValue = previousState.axes[axisName] || 0;
  228. currentState.axes[axisName] = button.value;
  229. // Emit axis change for analog triggers
  230. if (Math.abs(button.value - previousValue) > 0.01) {
  231. this.emit('axischange', {
  232. gamepadIndex: gamepad.index,
  233. axis: axisName,
  234. value: button.value,
  235. rawValue: button.value
  236. });
  237. }
  238. }
  239. // Detect button press (with debouncing)
  240. if (isPressed && !wasPressed) {
  241. this.handleButtonPress(gamepad.index, buttonName, button.value);
  242. }
  243. // Detect button release
  244. if (!isPressed && wasPressed) {
  245. this.handleButtonRelease(gamepad.index, buttonName);
  246. }
  247. });
  248. }
  249. processAxes(gamepad, controllerData, previousState, currentState) {
  250. const mapping = controllerData.mapping.axes;
  251. gamepad.axes.forEach((axisValue, axisIndex) => {
  252. const axisName = mapping[axisIndex];
  253. if (!axisName) return;
  254. // DPAD_AXIS is immune to deadzone filtering and gets special processing
  255. let processedValue;
  256. if (axisName === 'DPAD_AXIS') {
  257. processedValue = this.processDpadAxis(axisValue);
  258. } else {
  259. // Apply deadzone to all other axes
  260. processedValue = this.applyDeadzone(axisValue);
  261. }
  262. const previousValue = previousState.axes[axisName] || 0;
  263. currentState.axes[axisName] = processedValue;
  264. // Only emit events if value changed significantly
  265. const threshold = axisName === 'DPAD_AXIS' ? 0.001 : 0.01;
  266. if (Math.abs(processedValue - previousValue) > threshold) {
  267. this.emit('axischange', {
  268. gamepadIndex: gamepad.index,
  269. axis: axisName,
  270. value: processedValue,
  271. rawValue: axisValue
  272. });
  273. }
  274. });
  275. }
  276. processDpadAxis(rawValue) {
  277. // DPAD axis mapping for Nintendo controllers (raw values to degrees):
  278. // 1.29 = neutral (not pressed) -> null
  279. // 0.14 = down -> 270°
  280. // -0.14 = down right -> 315°
  281. // -0.43 = right -> 0°
  282. // -0.71 = up right -> 45°
  283. // -1 = up -> 90°
  284. // 1 = up left -> 135°
  285. // 0.71 = left -> 180°
  286. // 0.43 = down left -> 225°
  287. // Use tolerance for floating point comparison
  288. const tolerance = 0.05;
  289. // Check for neutral position first
  290. if (Math.abs(rawValue - 1.29) < tolerance) {
  291. return null; // Not pressed
  292. }
  293. // Map raw values to degrees
  294. if (Math.abs(rawValue - (-0.43)) < tolerance) {
  295. return 0; // Right
  296. } else if (Math.abs(rawValue - (-0.71)) < tolerance) {
  297. return 45; // Up right
  298. } else if (Math.abs(rawValue - (-1)) < tolerance) {
  299. return 90; // Up
  300. } else if (Math.abs(rawValue - 1) < tolerance) {
  301. return 135; // Up left
  302. } else if (Math.abs(rawValue - 0.71) < tolerance) {
  303. return 180; // Left
  304. } else if (Math.abs(rawValue - 0.43) < tolerance) {
  305. return 225; // Down left
  306. } else if (Math.abs(rawValue - 0.14) < tolerance) {
  307. return 270; // Down
  308. } else if (Math.abs(rawValue - (-0.14)) < tolerance) {
  309. return 315; // Down right
  310. }
  311. // Return null for unrecognized values (treat as neutral)
  312. return null;
  313. }
  314. applyDeadzone(value) {
  315. if (Math.abs(value) < this.config.deadzone) {
  316. return 0;
  317. }
  318. // Scale the remaining range to 0-1
  319. const sign = Math.sign(value);
  320. const scaledValue = (Math.abs(value) - this.config.deadzone) / (1 - this.config.deadzone);
  321. return sign * scaledValue;
  322. }
  323. handleButtonPress(gamepadIndex, buttonName, value) {
  324. // Implement debouncing
  325. const timerId = `${gamepadIndex}_${buttonName}`;
  326. if (!this.buttonDebounceTimers.has(gamepadIndex)) {
  327. this.buttonDebounceTimers.set(gamepadIndex, {});
  328. }
  329. const gamepadTimers = this.buttonDebounceTimers.get(gamepadIndex);
  330. if (gamepadTimers[buttonName]) {
  331. clearTimeout(gamepadTimers[buttonName]);
  332. }
  333. gamepadTimers[buttonName] = setTimeout(() => {
  334. delete gamepadTimers[buttonName];
  335. this.emit('buttondown', {
  336. gamepadIndex,
  337. button: buttonName,
  338. value: value || 1
  339. });
  340. }, this.config.debounceTime);
  341. }
  342. handleButtonRelease(gamepadIndex, buttonName) {
  343. this.emit('buttonup', {
  344. gamepadIndex,
  345. button: buttonName
  346. });
  347. }
  348. // Event system
  349. on(eventType, callback) {
  350. if (!this.eventListeners.has(eventType)) {
  351. this.eventListeners.set(eventType, []);
  352. }
  353. this.eventListeners.get(eventType).push(callback);
  354. }
  355. off(eventType, callback) {
  356. if (!this.eventListeners.has(eventType)) return;
  357. const listeners = this.eventListeners.get(eventType);
  358. const index = listeners.indexOf(callback);
  359. if (index > -1) {
  360. listeners.splice(index, 1);
  361. }
  362. }
  363. emit(eventType, data) {
  364. if (!this.eventListeners.has(eventType)) return;
  365. this.eventListeners.get(eventType).forEach(callback => {
  366. try {
  367. callback(data);
  368. } catch (error) {
  369. console.error(`Error in gamepad event listener for ${eventType}:`, error);
  370. }
  371. });
  372. }
  373. // Utility methods
  374. getGamepadInfo(index) {
  375. return this.connectedGamepads.get(index);
  376. }
  377. getConnectedGamepads() {
  378. return Array.from(this.connectedGamepads.keys());
  379. }
  380. setDeadzone(deadzone) {
  381. this.config.deadzone = Math.max(0, Math.min(1, deadzone));
  382. }
  383. setDebounceTime(time) {
  384. this.config.debounceTime = Math.max(0, time);
  385. }
  386. // Vibration support (if available)
  387. vibrate(gamepadIndex, duration = 200, strongMagnitude = 1.0, weakMagnitude = 1.0) {
  388. if (!this.config.enableVibration) return false;
  389. const gamepads = navigator.getGamepads();
  390. const gamepad = gamepads[gamepadIndex];
  391. if (!gamepad || !gamepad.vibrationActuator) return false;
  392. try {
  393. gamepad.vibrationActuator.playEffect('dual-rumble', {
  394. duration,
  395. strongMagnitude: Math.max(0, Math.min(1, strongMagnitude)),
  396. weakMagnitude: Math.max(0, Math.min(1, weakMagnitude))
  397. });
  398. return true;
  399. } catch (error) {
  400. console.warn('Vibration not supported:', error);
  401. return false;
  402. }
  403. }
  404. destroy() {
  405. this.stopPolling();
  406. // Clear all timers
  407. for (const timers of this.buttonDebounceTimers.values()) {
  408. Object.values(timers).forEach(timer => clearTimeout(timer));
  409. }
  410. // Clear all event listeners
  411. this.eventListeners.clear();
  412. // Remove window event listeners
  413. window.removeEventListener('gamepadconnected', this.handleGamepadConnected);
  414. window.removeEventListener('gamepaddisconnected', this.handleGamepadDisconnected);
  415. }
  416. }