javascriptreactjsgamepad-api

How to detect a sequence of button press with gamepad api


I am trying to use the Gamepad API in react to detect a sequence of button or key presses such as the konami code. I found a blog post that shows how to detect when a gamepad is connected and I can do that as well as handle a state change however I'm stuck when it comes to detecting a sequence. The reason is that there is no 'onKeyUp' type of event and if a button is held it will emit an event every 1/60th of a second (because I am using requestAnimationFrame). I made a useGamepads hook following the blog and I have a Controller component where I am trying to detect the sequence.

export default function useGamepads(callback) {
  const gamepads = useRef({});
  const requestRef = useRef();

  var haveEvents = "ongamepadconnected" in window;

  const addGamepad = (gamepad) => {

    // console.log('gamepad: ', gamepad);

    gamepads.current = {
      ...gamepads.current,
      [gamepad.index]: {
        id: gamepad.id,
        axes: gamepad.axes,
        buttons: gamepad.buttons,
        connected: gamepad.connected,
        mapping: gamepad.mapping,
        index: gamepad.index,
        vibrationActuator: gamepad.vibrationActuator,
      }
    };

    callback(gamepads.current);
  };

  const connectGamepadHandler = (e) => {
    addGamepad(e.gamepad);
  };

  const scanGamepads = () => {
    // Grab gamepads from browser API
    const detectedGamepads = navigator.getGamepads
      ? navigator.getGamepads()
      : navigator.webkitGetGamepads
      ? navigator.webkitGetGamepads()
      : [];

    // Loop through all detected controllers and add if not already in state
    for (let i = 0; i < detectedGamepads.length; i++) {
      if (detectedGamepads[i]) {
        addGamepad(detectedGamepads[i]);
      }
    }
  };

  // Add event listener for gamepad connecting
  useEffect(() => {
    window.addEventListener("gamepadconnected", connectGamepadHandler);
    return window.removeEventListener("gamepadconnected", connectGamepadHandler);
  }, []);

  // Update each gamepad's status on each "tick"
  const animate = (time) => {
    if (!haveEvents) scanGamepads();
    requestRef.current = requestAnimationFrame(animate);
  };

  useEffect(() => {
    requestRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(requestRef.current);
  }, []);

  return gamepads.current;
}
const Controller = ({
  activeColor = "#2F80ED",
  inactiveColor = "#E0E0E0",
  showController = true,
  showControllerName = true,
  showLastControllerUpdate = true,
  onKonamiUnlocked = () => {},
}) => {
  
  const [gamepad, setGamepad] = useState({});
  const [gamepads, setGamepads] = useState(null);

  const [lastControllerUpdate, setLastControllerUpdate] = useState({});
  const [controllerName, setControllerName] = useState('');

  // konami
  const konamiCodeSequence = ['directionUp', 'directionUp', 'directionDown', 'directionDown', 'directionLeft', 'directionRight', 'directionLeft', 'directionRight', 'buttonDown', 'buttonRight'];
  const [sequence, setSequence] = useState([]);
  const [konamiUnlocked, setKonamiUnlocked] = useState(false);

    
  useGamepads((gp) => setGamepads(gp));

  const debouncedSetSequence = debounce(setSequence, 750);
  const throttledSetSequence = throttle(setSequence, 750);

  useEffect(() => {
    if (!lastControllerUpdate) return;
    throttledSetSequence(prev => {
      return [...prev, lastControllerUpdate];
    });
  }, [lastControllerUpdate]); // gamepad

  

  const calcDirectionVertical = (axe) => {
    // Up
    if (axe < -0.2) {
      return "up";
    }
    // Down
    if (axe > 0.2) {
      return "down";
    }
  };

  const calcDirectionHorizontal = (axe) => {
    // Left
    if (axe < -0.2) {
      return "left";
    }
    // Right
    if (axe > 0.2) {
      return "right";
    }
  };

  const createTransform = (direction) => {
    switch (direction) {
      case "up":
        return "translateY(-10px)";
      case "down":
        return "translateY(10px)";
      case "left":
        return "translateX(-10px)";
      case "right":
        return "translateX(10px)";

      default:
        return "";
    }
  };

  const onGamepadUpdate = (newGamePadState) => {
    for (const [key, value] of Object.entries(newGamePadState)) {
      if (typeof value === "boolean" && value === true) {
        const newVal = { 
          id: Math.random().toString(36).substr(2, 4),
          val: key.toString(),
        };
        // console.log('newVal: ', newVal);
        setLastControllerUpdate(newVal); // key.toString());
      }
    }
  };

  const throttledGamepadUpdate = throttle(onGamepadUpdate, 1000);
  const debouncedGamepadUpdate = debounce(onGamepadUpdate, 1000);

  useEffect(() => {

    if (gamepads && gamepads.length !== 0) {

      if (controllerName === '') {
        setControllerName(gamepads[0].id);
      }

      const newGamePadState = {
        directionUp: gamepads[0].buttons[12].pressed,
        directionDown: gamepads[0].buttons[13].pressed,
        directionLeft: gamepads[0].buttons[14].pressed,
        directionRight: gamepads[0].buttons[15].pressed,
        buttonDown: gamepads[0].buttons[0].pressed,
        buttonRight: gamepads[0].buttons[1].pressed,
        buttonLeft: gamepads[0].buttons[2].pressed,
        buttonUp: gamepads[0].buttons[3].pressed,
        buttonX: gamepads[0].buttons[16].pressed,
        // top of controller
        buttonLT: gamepads[0].buttons[6].pressed,
        buttonLB: gamepads[0].buttons[4].pressed,
        buttonRT: gamepads[0].buttons[7].pressed,
        buttonRB: gamepads[0].buttons[5].pressed,

        select: gamepads[0].buttons[8].pressed,
        start: gamepads[0].buttons[9].pressed,
        analogLeft: gamepads[0].axes[0] > 0.3 || gamepads[0].axes[0] < -0.3 || gamepads[0].axes[1] > 0.3 || gamepads[0].axes[1] < -0.3,
        analogRight: gamepads[0].axes[2] > 0.3 || gamepads[0].axes[2] < -0.3 || gamepads[0].axes[3] > 0.3 || gamepads[0].axes[3] < -0.3,
        analogLeftDirection: [
          calcDirectionHorizontal(gamepads[0].axes[0]),
          calcDirectionVertical(gamepads[0].axes[1])
        ],
        analogRightDirection: [
          calcDirectionHorizontal(gamepads[0].axes[2]),
          calcDirectionVertical(gamepads[0].axes[3])
        ],
        
      };

      // throttle and debounce do not seem to work...
      throttledGamepadUpdate(newGamePadState);
      // debouncedGamepadUpdate(newGamePadState);
      // onGamepadUpdate(newGamePadState);
      

      setGamepad({ ...newGamePadState });
    }
  }, [gamepads]);

  const {
    directionUp,
    directionRight,
    directionDown,
    directionLeft,
    select,
    start,
    buttonUp,
    buttonRight,
    buttonDown,
    buttonLeft,
    analogLeft,
    analogLeftDirection,
    analogRight,
    analogRightDirection,
  } = gamepad;

  return (
    <div>
      { showController && 
        (
          <svg width={288} height={144} viewBox="0 0 1280 819" fill="none" >
            <path
              className="background"
              d="M209.5 7.246c11.7-2.7 26.5-5.2 38.5-6.6 12.5-1.4 38.5-.4 49 1.8 19.7 4.3 31.2 10.6 43.7 24.1 7.8 8.4 21.9 28.7 25.2 36.4 4.4 10.1 12.6 47.8 12.6 58.3v3.1h522v-3.1c0-5.2 4.8-32.2 7.6-43 3.5-13.1 6-18.6 13.5-29.9 12-17.9 23.6-30.5 33.3-36.2 6.4-3.7 19-8.1 29.2-10.1 11-2.2 40.4-2.5 54.4-.5 26.1 3.6 47.3 9.1 61 15.8 21 10.2 31.8 27.5 41.4 66 1.9 7.6 4 16.3 4.6 19.4l1.1 5.5 11.2 8c29 20.4 53.9 42.9 63.3 57.1 11.4 17.1 20.1 37.4 28.8 67.5 7.1 24.6 7.5 27.6 17.5 138.3 9.3 101.8 11.5 142.5 11.6 213 0 54.6-1.2 87.9-4 110.6-3.5 27.8-13.4 49.3-31.2 68-23.4 24.5-47.6 38.4-78.6 45.1-14.5 3.1-41.5 3.1-53 0-16.6-4.5-33.9-14.7-51.7-30.5-24.5-21.7-42.3-49.1-72.6-111.7-18.2-37.4-19.9-40.6-26.2-47.5-3.1-3.3-8-9.3-10.9-13.2l-5.4-7.3-10.2 8.3c-23.1 18.7-34.4 24.2-60.9 29.8-12.4 2.6-36.9 3.1-48.8 1-27.3-4.8-51.2-13.8-71-26.9-17.2-11.4-27.6-24.6-41.3-52.4l-7.2-14.6H573l-7.2 14.6c-13.7 27.8-24.1 41-41.3 52.4-20.1 13.2-43.7 22.1-71 26.9-11.9 2.1-36.4 1.6-48.8-1-26.5-5.6-37.8-11.1-60.9-29.8l-10.2-8.3-5.4 7.3c-3 3.9-8 10.1-11.3 13.7-4 4.4-7.6 9.9-11.1 17-2.8 5.8-10.8 22-17.6 36-28.5 58.3-47.1 86.1-71.4 107.1-17.8 15.4-33.8 24.7-50.1 29.1-11.4 3.1-38.5 3.1-52.9 0-31-6.7-55.2-20.6-78.6-45.1-17.8-18.7-27.7-40.2-31.2-68-2.8-22.7-4-56-4-110.6.1-70.4 2.3-111.1 11.6-213 10.2-112.6 10-111.3 15.9-132.9 8-29.2 17-51.6 27.4-68.6 10-16.2 33.5-38 65.4-60.8 6.4-4.5 11.7-8.4 11.8-8.5.2-.1 1.7-6.8 3.4-14.7 6.1-27.9 16.2-53.4 24.5-62.2 11.4-12 24.5-18.4 49.5-24.2z"
              fill="#C4C4C4"
            />
            <path
              className="direction_up"
              d="M269 165h-77v56c9.333 11.333 30 34 38 34s29.333-22.667 39-34v-56z"
              fill={directionUp ? activeColor : inactiveColor}
            />
            <path
              className="direction_right"
              d="M341 240v77h-56c-11.333-9.333-34-30-34-38s22.667-29.333 34-39h56z"
              fill={directionRight ? activeColor : inactiveColor}
            />
            <path
              className="direction_down"
              d="M269 392h-77v-56c9.333-11.333 30-34 38-34s29.333 22.667 39 34v56z"
              fill={directionDown ? activeColor : inactiveColor}
            />
            <path
              className="direction_left"
              d="M119 240v77h56c11.333-9.333 34-30 34-38s-22.667-29.333-34-39h-56z"
              fill={directionLeft ? activeColor : inactiveColor}
            />
            <path
              className="select"
              fill={select ? activeColor : inactiveColor}
              d="M471 262h75v47h-75z"
            />
            <path
              className="start"
              d="M728 309v-49l72 23-72 26z"
              fill={start ? activeColor : inactiveColor}
            />
            <circle
              className="button_up"
              cx={1050.5}
              cy={183.5}
              r={47.5}
              fill={buttonUp ? activeColor : inactiveColor}
            />
            <circle
              className="button_right"
              cx={1162.5}
              cy={283.5}
              r={47.5}
              fill={buttonRight ? activeColor : inactiveColor}
            />
            <circle
              className="button_down"
              cx={1050.5}
              cy={383.5}
              r={47.5}
              fill={buttonDown ? activeColor : inactiveColor}
            />
            <circle
              className="button_left"
              cx={935.5}
              cy={283.5}
              r={47.5}
              fill={buttonLeft ? activeColor : inactiveColor}
            />
            <circle
              className="analog_left"
              cx={429}
              cy={511}
              r={93}
              fill={analogLeft ? activeColor : inactiveColor}
              style={{
                position: "relative",
                transition: "transform 200ms ease-out",
                transform: analogLeftDirection && analogLeftDirection.length > 0 ? `${createTransform(analogLeftDirection[0])} ${createTransform(analogLeftDirection[1])}` : "",
              }}
            />
            <circle
              className="analog_right"
              cx={843}
              cy={511}
              r={93}
              fill={analogRight ? activeColor : inactiveColor}
              style={{
                position: "relative",
                transition: "transform 200ms ease-out",
                transform: analogRightDirection && analogRightDirection.length > 0 ? `${createTransform(analogRightDirection[0])} ${createTransform(analogRightDirection[1])}` : "",
              }}
            />
          </svg>
        )
      }
    </div>
  );
};

export default Controller;

Solution

  • I solved this by implementing my own checks for a button down / up event. I have now implemented this using CustomEvents and I also allow the user of the hook specify callbacks. Anyways, if someone is interested in the code I can post the full solution. I also decided to publish it as a package to npm if someone is interested: awesome-react-gamepads. It is still in development but there is a link to the repo and source.