From b8b88bd99fb3a701dd16737c20acd7844a18cee0 Mon Sep 17 00:00:00 2001 From: Jag_k Date: Tue, 11 Jan 2022 04:18:37 +0300 Subject: [PATCH 1/5] Add support Joy-cons and other gamepads. This solution using [Gamepad API](https://developer.mozilla.org/en/docs/Web/API/Gamepad_API) --- lib/modules/gamepad.js | 217 +++++++++++++++++++++++++++++++++++++++++ lib/modules/install.js | 2 + 2 files changed, 219 insertions(+) create mode 100644 lib/modules/gamepad.js diff --git a/lib/modules/gamepad.js b/lib/modules/gamepad.js new file mode 100644 index 0000000..36af416 --- /dev/null +++ b/lib/modules/gamepad.js @@ -0,0 +1,217 @@ +// `requestID` to cancel pseudo-animation +// For more detail check this docs: +// https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame#return_value +// https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelAnimationFrame#parameters +let requestID = null; +const buttonsCache = {}; + +/** + * Return key for {@link buttonsCache} by {@link Gamepad}. + * @param gp {Gamepad} + * @return {`${string} ${number}`} + */ +const getCacheId = (gp) => `${gp.id} ${gp.index}`; + +/** + * Check button is pressed + * @param button {GamepadButton || number} + * @return {boolean} + */ +const buttonPressed = (button) => { + if (typeof button === 'object') { + return button.pressed; + } + return button === 1.0; +}; + +/** + * Check buttons is pressed by indexes in {@link Gamepad.buttons} Array + * @param gp {Gamepad} + * @param buttonIndexes {number[]} - Array of indexes for {@link Gamepad.buttons} + * @param isAny {boolean} - If `true` (by default) – {@link buttonsPressed} return `true` + * if any button is pressed, else {@link buttonsPressed} return `true` if all buttons pressed. + * @return {boolean} + */ +const buttonsPressed = (gp, buttonIndexes, isAny = true) => { + const { buttons } = gp; + return buttonIndexes + .map((btn) => buttonPressed(buttons[btn])) + .reduce((acc, btnStatus) => (isAny ? acc || btnStatus : acc && btnStatus), false); +}; + +/** + * Using axes as buttons + * @param axes {number[]} + * @param moreOrLess {'more' | 'less'} + * @param value {number} + * @return {boolean} + */ +const axesToBoolean = (axes, moreOrLess, value) => { + const ratio = moreOrLess === 'more' ? -1 : 1; + return axes.reduce((acc, val) => acc || ratio * val > -ratio * value, false); +}; + +/** + * Return mapped actions by buttons on gamepad. {@link buttonMapping} can detect Joy-cons. + * @param gp {Gamepad} + * @return {{ + * next: boolean, + * prev: boolean, + * toggleFull: boolean, + * exitFull: boolean, + * }} + * @see https://w3c.github.io/gamepad/#remapping + */ +const buttonMapping = (gp) => { + const { id } = gp; + + /** + * Shortcut for {@link buttonsPressed} + * @param buttonIndexes {number} + * @return {boolean} + */ + const b = (...buttonIndexes) => buttonsPressed(gp, buttonIndexes); + const axes = gp.axes.map((value, index) => (index % 2 === 1 ? -value : value)); + const exitFull = b(16); + + if (id.startsWith('Joy-Con')) { + const toggleFull = b(9, 10); + + const axesPositive = axesToBoolean(gp.axes, 'more', 0.7); + const axesNegative = axesToBoolean(gp.axes, 'less', -0.7); + + if (id.startsWith('Joy-Con (R)')) { + return { + // Buttons: A, X, SR, ZR + // Sticks directions: Up and Right + next: b(0, 1, 5, 7) || axesPositive, + + // Buttons: B, Y, SL, R + // Sticks directions: Down and Left + prev: b(2, 3, 4, 8) || axesNegative, + + // Buttons: Plus, RStick + toggleFull, + + // Buttons: Home + exitFull, + }; + } + + if (id.startsWith('Joy-Con (L)')) { + return { + // Buttons: Up, Right, SL, ZL + // Sticks directions: Up and Right + next: b(2, 3, 4, 6) || axesNegative, + + // Buttons: Left, Down, SR, L + // Sticks directions: Down and Left + prev: b(0, 1, 5, 8) || axesPositive, + + // Buttons: Minus, LStick + toggleFull, + + // Buttons: Screenshot (looks like a circle in a square) + exitFull, + }; + } + + return { + // Buttons: A, X, ZL, ZR, Up, Right, SL (on left Joy-con), SR (on right Joy-con) + // Sticks directions: Up and Right (On each Joy-cons) + next: b(1, 3, 6, 7, 12, 15, 18, 21) || axesToBoolean(axes, 'more', 0.7), + + // Buttons: B, Y, L, R, Bottom, Left, SR (on left Joy-con), SL (on right Joy-con) + // Sticks directions: Down and Left (On each Joy-cons) + prev: b(0, 2, 4, 5, 13, 14, 19, 20) || axesToBoolean(axes, 'less', -0.7), + + // Buttons: Minus, Plus, LStick, RStick + toggleFull: b(8, 9, 10, 11), + + // Buttons: Home + exitFull, + }; + } + + // Buttons name from XBox Gamepad + return { + // Buttons: A, X, LT, RT, Up, Right + // Sticks directions: Up and Right (On each stick) + next: b(0, 2, 6, 7, 12, 15) || axesToBoolean(axes, 'more', 0.7), + + // Buttons: B, Y, LB, RB, Bottom, Left + // Sticks directions: Down and Left (On each stick) + prev: b(1, 3, 4, 5, 13, 14) || axesToBoolean(axes, 'less', -0.7), + + // Buttons: Select, Start, LStick, RStick + toggleFull: b(8, 9, 10, 11), + + // Buttons: XBox button (home) + exitFull, + }; +}; + +const ShowerActionOnButton = { + /** @param shower {Shower} */ + next: (shower) => shower.next(), + /** @param shower {Shower} */ + prev: (shower) => shower.prev(), + /** @param shower {Shower} */ + toggleFull: (shower) => (shower.isFullMode ? shower.exitFullMode() : shower.enterFullMode()), + /** @param shower {Shower} */ + exitFull: (shower) => shower.exitFullMode(), +}; + +/** + * @param shower {Shower} + */ +const gamepadLoop = (shower) => { + return () => { + for (const gp of navigator.getGamepads()) { + if (gp) { + const index = `${gp.id} ${gp.index}`; + if (!buttonsCache[index]) { + buttonsCache[index] = {}; + } + + const buttonsState = buttonMapping(gp); + const cache = buttonsCache[index]; + for (const key of Object.keys(buttonsState)) { + if (buttonsState[key] && cache[key] !== buttonsState[key]) { + // console.log('New button:', key); + ShowerActionOnButton[key](shower); + } + } + Object.assign(buttonsCache[index], buttonsState); + } + } + requestID = requestAnimationFrame(gamepadLoop(shower)); + }; +}; + +/** + * @param shower {Shower} + */ +export default function gamepad(shower) { + window.addEventListener('gamepadconnected', (event) => { + const gp = event.gamepad; + // console.log( + // `Gamepad connected at index ${gp.index}: ${gp.id}.`, + // `It has ${gp.buttons.length} buttons and ${gp.axes.length} axes.`, + // ); + buttonsCache[getCacheId(gp)] = {}; + if (!requestID) { + requestID = requestAnimationFrame(gamepadLoop(shower)); + } + }); + + window.addEventListener('gamepaddisconnected', (event) => { + const gp = event.gamepad; + // console.log(`Gamepad disconnected at index ${gp.index}: ${gp.id}.`); + delete buttonsCache[getCacheId(gp)]; + if (Object.keys(buttonsCache).length === 0) { + cancelAnimationFrame(requestID); + requestID = null; + } + }); +} diff --git a/lib/modules/install.js b/lib/modules/install.js index 3c50dca..c7342d2 100644 --- a/lib/modules/install.js +++ b/lib/modules/install.js @@ -8,6 +8,7 @@ import title from './title'; import view from './view'; import touch from './touch'; import mouse from './mouse'; +import gamepad from './gamepad'; export default (shower) => { a11y(shower); @@ -20,6 +21,7 @@ export default (shower) => { view(shower); touch(shower); mouse(shower); + gamepad(shower); // maintains invariant: active slide always exists in `full` mode if (shower.isFullMode && !shower.activeSlide) { From ae886882ec2e7ebe94caa10419af132dbacd2e44 Mon Sep 17 00:00:00 2001 From: Jag_k Date: Tue, 11 Jan 2022 04:23:50 +0300 Subject: [PATCH 2/5] Update link to gamepad remapping documentation --- lib/modules/gamepad.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/gamepad.js b/lib/modules/gamepad.js index 36af416..81856de 100644 --- a/lib/modules/gamepad.js +++ b/lib/modules/gamepad.js @@ -60,7 +60,7 @@ const axesToBoolean = (axes, moreOrLess, value) => { * toggleFull: boolean, * exitFull: boolean, * }} - * @see https://w3c.github.io/gamepad/#remapping + * @see https://www.w3.org/TR/gamepad/#remapping */ const buttonMapping = (gp) => { const { id } = gp; From cf0199af7c8091706ec45a1b7797a831df9a90db Mon Sep 17 00:00:00 2001 From: Jag_k Date: Wed, 12 Jan 2022 03:36:06 +0300 Subject: [PATCH 3/5] Swapped argument and type in JSDoc --- lib/modules/gamepad.js | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/modules/gamepad.js b/lib/modules/gamepad.js index 81856de..2f31ff0 100644 --- a/lib/modules/gamepad.js +++ b/lib/modules/gamepad.js @@ -7,14 +7,14 @@ const buttonsCache = {}; /** * Return key for {@link buttonsCache} by {@link Gamepad}. - * @param gp {Gamepad} + * @param {Gamepad} gp * @return {`${string} ${number}`} */ const getCacheId = (gp) => `${gp.id} ${gp.index}`; /** * Check button is pressed - * @param button {GamepadButton || number} + * @param {GamepadButton || number} button * @return {boolean} */ const buttonPressed = (button) => { @@ -26,9 +26,9 @@ const buttonPressed = (button) => { /** * Check buttons is pressed by indexes in {@link Gamepad.buttons} Array - * @param gp {Gamepad} - * @param buttonIndexes {number[]} - Array of indexes for {@link Gamepad.buttons} - * @param isAny {boolean} - If `true` (by default) – {@link buttonsPressed} return `true` + * @param {Gamepad} gp + * @param {number[]} buttonIndexes - Array of indexes for {@link Gamepad.buttons} + * @param {boolean} isAny - If `true` (by default) – {@link buttonsPressed} return `true` * if any button is pressed, else {@link buttonsPressed} return `true` if all buttons pressed. * @return {boolean} */ @@ -41,9 +41,9 @@ const buttonsPressed = (gp, buttonIndexes, isAny = true) => { /** * Using axes as buttons - * @param axes {number[]} - * @param moreOrLess {'more' | 'less'} - * @param value {number} + * @param {number[]} axes + * @param {'more' | 'less'} moreOrLess + * @param {number} value * @return {boolean} */ const axesToBoolean = (axes, moreOrLess, value) => { @@ -53,7 +53,7 @@ const axesToBoolean = (axes, moreOrLess, value) => { /** * Return mapped actions by buttons on gamepad. {@link buttonMapping} can detect Joy-cons. - * @param gp {Gamepad} + * @param {Gamepad} gp * @return {{ * next: boolean, * prev: boolean, @@ -67,7 +67,7 @@ const buttonMapping = (gp) => { /** * Shortcut for {@link buttonsPressed} - * @param buttonIndexes {number} + * @param {number} buttonIndexes * @return {boolean} */ const b = (...buttonIndexes) => buttonsPressed(gp, buttonIndexes); @@ -152,18 +152,25 @@ const buttonMapping = (gp) => { }; const ShowerActionOnButton = { - /** @param shower {Shower} */ + /** @param {Shower} shower */ next: (shower) => shower.next(), - /** @param shower {Shower} */ + /** @param {Shower} shower */ prev: (shower) => shower.prev(), - /** @param shower {Shower} */ - toggleFull: (shower) => (shower.isFullMode ? shower.exitFullMode() : shower.enterFullMode()), - /** @param shower {Shower} */ + /** @param {Shower} shower */ + toggleFull: (shower) => { + if (shower.isFullMode) { + shower.exitFullMode(); + } else { + if (shower.activeSlideIndex === -1) shower.first(); + shower.enterFullMode(); + } + }, + /** @param {Shower} shower */ exitFull: (shower) => shower.exitFullMode(), }; /** - * @param shower {Shower} + * @param {Shower} shower */ const gamepadLoop = (shower) => { return () => { @@ -190,7 +197,7 @@ const gamepadLoop = (shower) => { }; /** - * @param shower {Shower} + * @param {Shower} shower */ export default function gamepad(shower) { window.addEventListener('gamepadconnected', (event) => { From ddf01a6033717abdcfbdee5a3faf0d49217575a0 Mon Sep 17 00:00:00 2001 From: Jag_k Date: Wed, 12 Jan 2022 03:37:01 +0300 Subject: [PATCH 4/5] Fix bug with wrong direction in axesToBoolean function --- lib/modules/gamepad.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/modules/gamepad.js b/lib/modules/gamepad.js index 2f31ff0..c65b591 100644 --- a/lib/modules/gamepad.js +++ b/lib/modules/gamepad.js @@ -47,8 +47,10 @@ const buttonsPressed = (gp, buttonIndexes, isAny = true) => { * @return {boolean} */ const axesToBoolean = (axes, moreOrLess, value) => { - const ratio = moreOrLess === 'more' ? -1 : 1; - return axes.reduce((acc, val) => acc || ratio * val > -ratio * value, false); + return axes.reduce( + (acc, val) => acc || (moreOrLess === 'more' ? val > value : val < value), + false, + ); }; /** From 798ad9d433593c72e78bb49d671f5265b05c855e Mon Sep 17 00:00:00 2001 From: Jag_k Date: Thu, 13 Jan 2022 02:42:42 +0300 Subject: [PATCH 5/5] Remove `console.log` comment --- lib/modules/gamepad.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/modules/gamepad.js b/lib/modules/gamepad.js index c65b591..cd34855 100644 --- a/lib/modules/gamepad.js +++ b/lib/modules/gamepad.js @@ -187,7 +187,6 @@ const gamepadLoop = (shower) => { const cache = buttonsCache[index]; for (const key of Object.keys(buttonsState)) { if (buttonsState[key] && cache[key] !== buttonsState[key]) { - // console.log('New button:', key); ShowerActionOnButton[key](shower); } } @@ -204,10 +203,6 @@ const gamepadLoop = (shower) => { export default function gamepad(shower) { window.addEventListener('gamepadconnected', (event) => { const gp = event.gamepad; - // console.log( - // `Gamepad connected at index ${gp.index}: ${gp.id}.`, - // `It has ${gp.buttons.length} buttons and ${gp.axes.length} axes.`, - // ); buttonsCache[getCacheId(gp)] = {}; if (!requestID) { requestID = requestAnimationFrame(gamepadLoop(shower)); @@ -216,7 +211,6 @@ export default function gamepad(shower) { window.addEventListener('gamepaddisconnected', (event) => { const gp = event.gamepad; - // console.log(`Gamepad disconnected at index ${gp.index}: ${gp.id}.`); delete buttonsCache[getCacheId(gp)]; if (Object.keys(buttonsCache).length === 0) { cancelAnimationFrame(requestID);