From bdbb307ff8cd19f8335bcfdcb7901a429cf8e0c5 Mon Sep 17 00:00:00 2001 From: Eric Anderson Date: Mon, 9 Oct 2023 16:32:50 -0700 Subject: [PATCH] kiosk: support gamepad dpad for navigation --- kiosk/src/Components/GameList.tsx | 52 +++++++-------- kiosk/src/Components/GameSlide.tsx | 10 +-- kiosk/src/Services/GamepadManager.ts | 96 ++++++++++++++++------------ kiosk/src/Services/NavGrid.ts | 35 ++++++---- kiosk/src/Utils/domUtils.ts | 6 ++ kiosk/src/config.json | 35 ++++++++-- 6 files changed, 146 insertions(+), 88 deletions(-) diff --git a/kiosk/src/Components/GameList.tsx b/kiosk/src/Components/GameList.tsx index 5ce31306a333..e61574fe0641 100644 --- a/kiosk/src/Components/GameList.tsx +++ b/kiosk/src/Components/GameList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useContext, useCallback } from "react"; +import React, { useEffect, useState, useContext, useCallback } from "react"; import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper as SwiperClass, Pagination } from "swiper"; import "swiper/css"; @@ -8,11 +8,7 @@ import { playSoundEffect } from "../Services/SoundEffectService"; import { AppStateContext } from "../State/AppStateContext"; import { selectGameByIndex } from "../Transforms/selectGameByIndex"; import { launchGame } from "../Transforms/launchGame"; -import { - getHighScores, - getSelectedGameIndex, - getSelectedGameId, -} from "../State"; +import { getSelectedGameIndex, getSelectedGameId } from "../State"; import * as GamepadManager from "../Services/GamepadManager"; import * as NavGrid from "../Services/NavGrid"; import { useOnControlPress, useMakeNavigable } from "../Hooks"; @@ -21,12 +17,18 @@ interface IProps {} const GameList: React.FC = ({}) => { const { state: kiosk } = useContext(AppStateContext); - const localSwiper = useRef(); + const [localSwiper, setLocalSwiper] = useState( + undefined + ); const [userInitiatedTransition, setUserInitiatedTransition] = React.useState(false); const [pageInited, setPageInited] = React.useState(false); - useMakeNavigable(localSwiper?.current?.el, { + const handleLocalSwiper = (swiper: SwiperClass) => { + setLocalSwiper(swiper); + }; + + useMakeNavigable(localSwiper?.el, { exitDirections: [NavGrid.NavDirection.Down, NavGrid.NavDirection.Up], autofocus: true, }); @@ -41,7 +43,8 @@ const GameList: React.FC = ({}) => { }; const syncSelectedGame = useCallback(() => { - const gameIndex = localSwiper?.current?.realIndex || 0; + if (!localSwiper || localSwiper.destroyed) return; + const gameIndex = localSwiper.realIndex || 0; const selectedGameIndex = getSelectedGameIndex(); if ( selectedGameIndex !== undefined && @@ -53,14 +56,15 @@ const GameList: React.FC = ({}) => { // on page load use effect useEffect(() => { + if (!localSwiper || localSwiper.destroyed) return; if (!pageInited) { if (kiosk.allGames.length) { if (!kiosk.selectedGameId) { selectGameByIndex(0); - localSwiper.current?.slideTo(2); + localSwiper.slideTo(2); } else { const index = getSelectedGameIndex() || 0; - localSwiper.current?.slideTo(index + 2); + localSwiper.slideTo(index + 2); } setPageInited(true); } @@ -71,9 +75,10 @@ const GameList: React.FC = ({}) => { useOnControlPress( [localSwiper, userInitiatedTransition], () => { - if (NavGrid.isActiveElement(localSwiper?.current?.el)) { + if (!localSwiper || localSwiper.destroyed) return; + if (NavGrid.isActiveElement(localSwiper.el)) { setUserInitiatedTransition(true); - setTimeout(() => localSwiper.current?.slideNext(), 1); + setTimeout(() => localSwiper.slideNext(), 1); } }, GamepadManager.GamepadControl.DPadRight @@ -83,9 +88,10 @@ const GameList: React.FC = ({}) => { useOnControlPress( [localSwiper, userInitiatedTransition], () => { - if (NavGrid.isActiveElement(localSwiper?.current?.el)) { + if (!localSwiper || localSwiper.destroyed) return; + if (NavGrid.isActiveElement(localSwiper.el)) { setUserInitiatedTransition(true); - setTimeout(() => localSwiper.current?.slidePrev(), 1); + setTimeout(() => localSwiper?.slidePrev(), 1); } }, GamepadManager.GamepadControl.DPadLeft @@ -95,12 +101,12 @@ const GameList: React.FC = ({}) => { useOnControlPress( [localSwiper, userInitiatedTransition], () => { - if (NavGrid.isActiveElement(localSwiper?.current?.el)) { + if (!localSwiper || localSwiper.destroyed) return; + if (NavGrid.isActiveElement(localSwiper.el)) { launchSelectedGame(); } }, - GamepadManager.GamepadControl.AButton, - GamepadManager.GamepadControl.BButton + GamepadManager.GamepadControl.AButton ); const transitionStart = () => { @@ -133,22 +139,16 @@ const GameList: React.FC = ({}) => { slidesPerView={1.8} centeredSlides={true} pagination={{ type: "fraction" }} - onSwiper={swiper => { - localSwiper.current = swiper; - }} + onSwiper={handleLocalSwiper} allowTouchMove={true} modules={[Pagination]} onSlideChangeTransitionStart={() => transitionStart()} onTouchEnd={() => onTouchEnd()} > {kiosk.allGames.map((game, index) => { - const gameHighScores = getHighScores(game.id); return ( - + ); })} diff --git a/kiosk/src/Components/GameSlide.tsx b/kiosk/src/Components/GameSlide.tsx index 16a2dc5218e6..dfc439e3412c 100644 --- a/kiosk/src/Components/GameSlide.tsx +++ b/kiosk/src/Components/GameSlide.tsx @@ -5,12 +5,12 @@ import { GameData, HighScore } from "../Types"; import { AppStateContext } from "../State/AppStateContext"; import HighScoresList from "./HighScoresList"; import { GameMenu } from "./GameMenu"; +import { getHighScores } from "../State"; interface IProps { - highScores: HighScore[]; game: GameData; } -const GameSlide: React.FC = ({ highScores, game }) => { +const GameSlide: React.FC = ({ game }) => { const { state: kiosk } = useContext(AppStateContext); const handleSlideClick = (ev?: React.MouseEvent) => { @@ -35,10 +35,12 @@ const GameSlide: React.FC = ({ highScores, game }) => {
{game.name}
{game.description}
- {game.date &&
{lf("Added {0}", game.date)}
} + {game.date && ( +
{lf("Added {0}", game.date)}
+ )} {kiosk.selectedGameId && game.id === kiosk.selectedGameId && ( diff --git a/kiosk/src/Services/GamepadManager.ts b/kiosk/src/Services/GamepadManager.ts index deae56bbceb3..e1a62869ba36 100644 --- a/kiosk/src/Services/GamepadManager.ts +++ b/kiosk/src/Services/GamepadManager.ts @@ -13,28 +13,36 @@ export enum GamepadControl { ResetButton = "ResetButton", } +enum GamepadAxis { + None = "None", + Up = "Up", + Down = "Down", + Left = "Left", + Right = "Right", +} + const gamepadControlToPinIndex: { [key in GamepadControl]: number } = { [GamepadControl.AButton]: configData.GamepadAButtonPin, [GamepadControl.BButton]: configData.GamepadBButtonPin, - [GamepadControl.DPadUp]: configData.GamepadUpDownAxis, - [GamepadControl.DPadDown]: configData.GamepadUpDownAxis, - [GamepadControl.DPadLeft]: configData.GamepadLeftRightAxis, - [GamepadControl.DPadRight]: configData.GamepadLeftRightAxis, + [GamepadControl.DPadUp]: configData.GamepadDpadUpButtonPin, + [GamepadControl.DPadDown]: configData.GamepadDpadDownButtonPin, + [GamepadControl.DPadLeft]: configData.GamepadDpadLeftButtonPin, + [GamepadControl.DPadRight]: configData.GamepadDpadRightButtonPin, [GamepadControl.MenuButton]: configData.GamepadMenuButtonPin, [GamepadControl.EscapeButton]: configData.GamepadEscapeButtonPin, [GamepadControl.ResetButton]: configData.GamepadResetButtonPin, }; -const gamepadControlToAxisDirection: { [key in GamepadControl]: number } = { - [GamepadControl.AButton]: 0, - [GamepadControl.BButton]: 0, - [GamepadControl.DPadUp]: -1, - [GamepadControl.DPadDown]: 1, - [GamepadControl.DPadLeft]: -1, - [GamepadControl.DPadRight]: 1, - [GamepadControl.MenuButton]: 0, - [GamepadControl.EscapeButton]: 0, - [GamepadControl.ResetButton]: 0, +const gamepadControlToAxis: { [key in GamepadControl]: GamepadAxis } = { + [GamepadControl.AButton]: GamepadAxis.None, + [GamepadControl.BButton]: GamepadAxis.None, + [GamepadControl.DPadUp]: GamepadAxis.Up, + [GamepadControl.DPadDown]: GamepadAxis.Down, + [GamepadControl.DPadLeft]: GamepadAxis.Left, + [GamepadControl.DPadRight]: GamepadAxis.Right, + [GamepadControl.MenuButton]: GamepadAxis.None, + [GamepadControl.EscapeButton]: GamepadAxis.None, + [GamepadControl.ResetButton]: GamepadAxis.None, }; export enum ControlValue { @@ -129,8 +137,7 @@ class GamepadManager { ); this.minAxisRequired = Math.max( - configData.GamepadUpDownAxis, - configData.GamepadLeftRightAxis + ...Object.values(configData.GamepadAxes).map(v => v.Pin) ); } @@ -258,6 +265,15 @@ class GamepadManager { } } + mergeControlValues(...values: ControlValue[]): ControlValue { + for (const value of values) { + if (value === ControlValue.Down) { + return ControlValue.Down; + } + } + return ControlValue.Up; + } + readGamepad(gamepad: Gamepad): ControlStates { return { [GamepadControl.AButton]: this.readGamepadButtonValue( @@ -268,21 +284,24 @@ class GamepadManager { gamepad, GamepadControl.BButton ), - [GamepadControl.DPadUp]: this.readGamepadDirectionValue( - gamepad, - GamepadControl.DPadUp + [GamepadControl.DPadUp]: this.mergeControlValues( + this.readGamepadButtonValue(gamepad, GamepadControl.DPadUp), + this.readGamepadDirectionValue(gamepad, GamepadControl.DPadUp) ), - [GamepadControl.DPadDown]: this.readGamepadDirectionValue( - gamepad, - GamepadControl.DPadDown + [GamepadControl.DPadDown]: this.mergeControlValues( + this.readGamepadButtonValue(gamepad, GamepadControl.DPadDown), + this.readGamepadDirectionValue(gamepad, GamepadControl.DPadDown) ), - [GamepadControl.DPadLeft]: this.readGamepadDirectionValue( - gamepad, - GamepadControl.DPadLeft + [GamepadControl.DPadLeft]: this.mergeControlValues( + this.readGamepadButtonValue(gamepad, GamepadControl.DPadLeft), + this.readGamepadDirectionValue(gamepad, GamepadControl.DPadLeft) ), - [GamepadControl.DPadRight]: this.readGamepadDirectionValue( - gamepad, - GamepadControl.DPadRight + [GamepadControl.DPadRight]: this.mergeControlValues( + this.readGamepadButtonValue(gamepad, GamepadControl.DPadRight), + this.readGamepadDirectionValue( + gamepad, + GamepadControl.DPadRight + ) ), [GamepadControl.MenuButton]: this.readGamepadButtonValue( gamepad, @@ -483,11 +502,8 @@ class GamepadManager { control: GamepadControl ): ControlValue { const pinIndex = gamepadControlToPinIndex[control]; - if (pinIndex < 0 || pinIndex >= gamepad.buttons.length) { - throw new Error( - `Gamepad at index ${gamepad.index} does not have a button at pin ${pinIndex}` - ); - } + if (pinIndex < 0 || pinIndex >= gamepad.buttons.length) + return ControlValue.Up; return gamepad.buttons[pinIndex].pressed ? ControlValue.Down : ControlValue.Up; @@ -497,15 +513,11 @@ class GamepadManager { gamepad: Gamepad, control: GamepadControl ): ControlValue { - const axisIndex = gamepadControlToPinIndex[control]; - const threshold = - gamepadControlToAxisDirection[control] * - configData.GamepadLeftRightThreshold; - if (axisIndex < 0 || axisIndex >= gamepad.axes.length) { - throw new Error( - `Gamepad at index ${gamepad.index} does not have an axis at index ${axisIndex}` - ); - } + const axisConf = configData.GamepadAxes[gamepadControlToAxis[control]]; + const axisIndex = axisConf.Pin; + if (axisIndex < 0 || axisIndex >= gamepad.axes.length) + return ControlValue.Up; + const threshold = axisConf.Sign * axisConf.Threshold; if (threshold < 0) { return gamepad.axes[axisIndex] <= threshold ? ControlValue.Down diff --git a/kiosk/src/Services/NavGrid.ts b/kiosk/src/Services/NavGrid.ts index 70f512acbdf9..c3e4311483a4 100644 --- a/kiosk/src/Services/NavGrid.ts +++ b/kiosk/src/Services/NavGrid.ts @@ -103,13 +103,13 @@ class NavGrid { this.stack.pop(); if (this.activeElement) { // Focus the previously active element - this.navigables.get(this.activeElement)?.el.focus(); + domUtils.focusElement(this.navigables.get(this.activeElement)?.el); } else { // Focus the first autofocus element in the context const navs = Array.from(this.navigables.values()); for (const navigable of navs) { if (navigable.autofocus) { - navigable.el.focus(); + domUtils.focusElement(navigable.el); break; } } @@ -206,15 +206,15 @@ class NavGrid { if (forward) { if (index === focusable.length - 1) { - focusable[0].focus(); + domUtils.focusElement(focusable[0]); } else { - focusable[index + 1].focus(); + domUtils.focusElement(focusable[index + 1]); } } else { if (index === 0) { - focusable[focusable.length - 1].focus(); + domUtils.focusElement(focusable[focusable.length - 1]); } else { - focusable[Math.max(index - 1, 0)].focus(); + domUtils.focusElement(focusable[Math.max(index - 1, 0)]); } } }; @@ -305,6 +305,10 @@ class NavGrid { opts?.autofocus ?? false ); + if (opts?.autofocus) { + el.setAttribute("autofocus", "autofocus"); + } + const onFocus = (ev: FocusEvent) => this.focusHandler(ev, navigable); const onBlur = () => this.blurHandler(navigable); const onResize = () => this.resizeHandler(navigable); @@ -323,7 +327,7 @@ class NavGrid { // If this is the first navigable and it allows autofocus, focus it if (!this.activeElement && opts?.autofocus) { - el.focus(); + domUtils.focusElement(el); } //console.log("NavGrid.registerNavigable", navigable); @@ -345,11 +349,18 @@ class NavGrid { navigate(direction: NavDirection): boolean { //console.log("navigate", direction); - // If nothing focused, focus the first registered navigable if it exists + // If nothing focused, focus the first registered autofocus navigable, + // if any. Otherwise focus the first registered navigable, if any. if (!this.activeElement) { - const keys = Array.from(this.navigables.keys()); - if (keys.length) { - this.navigables.get(keys[0])?.el?.focus(); + const values = Array.from(this.navigables.values()); + const autofocusNavigable = values.filter(n => n.autofocus)?.shift(); + if (autofocusNavigable) { + domUtils.focusElement(autofocusNavigable.el); + return true; + } + const firstNavigable = values.shift(); + if (firstNavigable) { + domUtils.focusElement(firstNavigable.el); return true; } // No navigables have been registered @@ -392,7 +403,7 @@ class NavGrid { if (!bestNavigable) { return false; } - bestNavigable.el.focus(); + domUtils.focusElement(bestNavigable.el); return true; } diff --git a/kiosk/src/Utils/domUtils.ts b/kiosk/src/Utils/domUtils.ts index c5f1e8d41d61..028534752503 100644 --- a/kiosk/src/Utils/domUtils.ts +++ b/kiosk/src/Utils/domUtils.ts @@ -43,3 +43,9 @@ export const isInteractable = ( isElementValid(element) ); }; + +export const focusElement = (el: HTMLElement | null | undefined): void => { + setTimeout(() => { + el?.focus({ focusVisible: true, preventScroll: true } as any); + }, 0); +}; diff --git a/kiosk/src/config.json b/kiosk/src/config.json index 7a83b012cf78..654187fd1acf 100644 --- a/kiosk/src/config.json +++ b/kiosk/src/config.json @@ -14,11 +14,38 @@ "GamepadBButtonPin": 1, "GamepadEscapeButtonPin": 9, "GamepadResetButtonPin": 10, + "GamepadDpadUpButtonPin": 12, + "GamepadDpadDownButtonPin": 13, + "GamepadDpadLeftButtonPin": 14, + "GamepadDpadRightButtonPin": 15, "GamepadMenuButtonPin": 11, - "GamepadLeftRightAxis": 0, - "GamepadLeftRightThreshold": 0.5, - "GamepadUpDownAxis": 1, - "GamepadUpDownThreshold": 0.5, + "GamepadAxes": { + "None": { + "Pin": -1, + "Sign": 0, + "Threshold": 0 + }, + "Up": { + "Pin": 1, + "Sign": -1, + "Threshold": 0.5 + }, + "Down": { + "Pin": 1, + "Sign": 1, + "Threshold": 0.5 + }, + "Left": { + "Pin": 0, + "Sign": -1, + "Threshold": 0.5 + }, + "Right": { + "Pin": 0, + "Sign": 1, + "Threshold": 0.5 + } + }, "GamepadControlToKeyboardKeyMap": { "AButton": "Space", "BButton": "Enter",