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;
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.