Skip to content

Commit

Permalink
gamepad: Make the deadzone circular and customizable, remember the pr…
Browse files Browse the repository at this point in the history
…evious direction (#1280)

closes #324
closes #1011
closes #1279
  • Loading branch information
GarboMuffin authored Feb 2, 2024
1 parent aeecde5 commit e746d76
Showing 1 changed file with 163 additions and 47 deletions.
210 changes: 163 additions & 47 deletions extensions/gamepad.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,71 +8,164 @@
(function (Scratch) {
"use strict";

const AXIS_DEADZONE = 0.1;
// For joysticks
const DEFAULT_AXIS_DEADZONE = 0.1;
let axisDeadzone = DEFAULT_AXIS_DEADZONE;

// For triggers. Drift isn't so big of an issue with these.
const BUTTON_DEADZONE = 0.05;

/**
* @param {number|'any'} index 1-indexed index
* @returns {Gamepad[]}
* @typedef InternalGamepadState
* @property {string} id
* @property {Gamepad} realGamepad
* @property {number} timestamp
* @property {number[]} axisDirections
* @property {number[]} axisMagnitudes
* @property {number[]} axisValues
* @property {number[]} buttonValues
* @property {boolean[]} buttonPressed
*/

/** @type {Array<InternalGamepadState|null>} */
let gamepadState = [];

const updateState = () => {
// In Firefox, the objects returned by getGamepads() change in the background, but in Chrome
// we have to call getGamepads() each frame. Easiest for us to just always call it.
// But because Firefox changes the objects in the background, we need to track old values
// ourselves.
const gamepads = navigator.getGamepads();

const oldState = gamepadState;

gamepadState = gamepads.map((gamepad) => {
if (!gamepad) {
return null;
}

/** @type {InternalGamepadState} */
const result = {
id: gamepad.id,
realGamepad: gamepad,
timestamp: gamepad.timestamp,
axisDirections: [],
axisMagnitudes: [],
axisValues: [],
buttonValues: [],
buttonPressed: [],
};

const oldResult = oldState.find((i) => i !== null && i.id === gamepad.id);

// Each pair of axes is given a circular deadzone.
for (let i = 0; i < gamepad.axes.length; i += 2) {
const x = gamepad.axes[i];
const y = i + 1 >= gamepad.axes.length ? 0 : gamepad.axes[i + 1];
const magnitude = Math.sqrt(x ** 2 + y ** 2);

if (magnitude > axisDeadzone) {
let direction = (Math.atan2(y, x) * 180) / Math.PI + 90;
if (direction < 0) {
direction += 360;
}

result.axisDirections.push(direction, direction);
result.axisMagnitudes.push(magnitude, magnitude);
result.axisValues.push(x, y);
} else {
// Set both axes to 0. Use the old direction state, if it exists, so that using the direction
// inside of something like "point in direction" won't reset when no inputs.
// If we have no information at all, default to 90 degrees, like new sprites.
const oldDirection = oldResult ? oldResult.axisDirections[i] : 90;
result.axisDirections.push(oldDirection, oldDirection);
result.axisMagnitudes.push(0, 0);
result.axisValues.push(0, 0);
}
}

for (let i = 0; i < gamepad.buttons.length; i++) {
let value = gamepad.buttons[i].value;
if (value < BUTTON_DEADZONE) {
value = 0;
}
result.buttonValues.push(value);
result.buttonPressed.push(gamepad.buttons[i].pressed);
}

return result;
});
};

Scratch.vm.runtime.on("BEFORE_EXECUTE", () => {
updateState();
});

/**
* @param {unknown} index 1-indexed index or 'any'
* @returns {InternalGamepadState[]}
*/
const getGamepads = (index) => {
if (index === "any") {
return navigator.getGamepads().filter((i) => i);
return gamepadState.filter((i) => i);
}
const gamepad = navigator.getGamepads()[index - 1];
const gamepad = gamepadState[Scratch.Cast.toNumber(index) - 1];
if (gamepad) {
return [gamepad];
}
return [];
};

/**
* @param {Gamepad} gamepad
* @param {number|'any'} buttonIndex 1-indexed index
* @param {InternalGamepadState} gamepad
* @param {unknown} buttonIndex 1-indexed index or 'any'
* @returns {boolean} false if button does not exist
*/
const isButtonPressed = (gamepad, buttonIndex) => {
if (buttonIndex === "any") {
return gamepad.buttons.some((i) => i.pressed);
}
const button = gamepad.buttons[buttonIndex - 1];
if (!button) {
return false;
return gamepad.buttonPressed.some((i) => i);
}
return button.pressed;
return !!gamepad.buttonPressed[Scratch.Cast.toNumber(buttonIndex) - 1];
};

/**
* @param {Gamepad} gamepad
* @param {number} buttonIndex 1-indexed index
* @param {InternalGamepadState} gamepad
* @param {unknown} buttonIndex 1-indexed index
* @returns {number} 0 if button does not exist
*/
const getButtonValue = (gamepad, buttonIndex) => {
const button = gamepad.buttons[buttonIndex - 1];
if (!button) {
return 0;
}
const value = button.value;
if (value < BUTTON_DEADZONE) {
return 0;
}
return value;
const value = gamepad.buttonValues[Scratch.Cast.toNumber(buttonIndex) - 1];
return value || 0;
};

/**
* @param {Gamepad} gamepad
* @param {number} axisIndex 1-indexed index
* @param {InternalGamepadState} gamepad
* @param {unknown} axisIndex 1-indexed index
* @returns {number} 0 if axis does not exist
*/
const getAxisValue = (gamepad, axisIndex) => {
const axisValue = gamepad.axes[axisIndex - 1];
if (typeof axisValue !== "number") {
return 0;
}
if (Math.abs(axisValue) < AXIS_DEADZONE) {
return 0;
}
return axisValue;
const axisValue = gamepad.axisValues[Scratch.Cast.toNumber(axisIndex) - 1];
return axisValue || 0;
};

/**
* @param {InternalGamepadState} gamepad
* @param {unknown} startIndex
*/
const getAxisPairMagnitude = (gamepad, startIndex) => {
const magnitude =
gamepad.axisMagnitudes[Scratch.Cast.toNumber(startIndex) - 1];
return magnitude || 0;
};

/**
* @param {InternalGamepadState} gamepad
* @param {unknown} startIndex
*/
const getAxisPairDirection = (gamepad, startIndex) => {
const direction =
gamepad.axisDirections[Scratch.Cast.toNumber(startIndex) - 1];
return direction || 0;
};

class GamepadExtension {
Expand Down Expand Up @@ -251,6 +344,20 @@
},
},
},

"---",

{
opcode: "setAxisDeadzone",
blockType: Scratch.BlockType.COMMAND,
text: Scratch.translate("set axis deadzone to [DEADZONE]"),
arguments: {
DEADZONE: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: DEFAULT_AXIS_DEADZONE.toString(),
},
},
},
],
menus: {
padMenu: {
Expand Down Expand Up @@ -441,20 +548,24 @@

axisDirection({ axis, pad }) {
let greatestMagnitude = 0;
// by default sprites have direction 90 degrees, so that's a reasonable default
let direction = 90;
for (const gamepad of getGamepads(pad)) {
const horizontalAxis = getAxisValue(gamepad, axis);
const verticalAxis = getAxisValue(gamepad, +axis + 1);
const magnitude = Math.sqrt(horizontalAxis ** 2 + verticalAxis ** 2);

const gamepads = getGamepads(pad);
for (const gamepad of gamepads) {
const magnitude = getAxisPairMagnitude(gamepad, axis);
if (magnitude > greatestMagnitude) {
greatestMagnitude = magnitude;
direction =
(Math.atan2(verticalAxis, horizontalAxis) * 180) / Math.PI + 90;
if (direction < 0) {
direction += 360;
}
direction = getAxisPairDirection(gamepad, axis);
}
}

// if no sticks are far enough out, instead we'll return the last direction
// of the most recently modified gamepad
if (greatestMagnitude === 0 && gamepads.length > 0) {
gamepads.sort((a, b) => b.timestamp - a.timestamp);
direction = getAxisPairDirection(gamepads[0], axis);
}

return direction;
}

Expand All @@ -473,11 +584,11 @@

rumble({ s, w, t, i }) {
const gamepads = getGamepads(i);
for (const gamepad of gamepads) {
for (const { realGamepad } of gamepads) {
// @ts-ignore
if (gamepad.vibrationActuator) {
if (realGamepad.vibrationActuator) {
// @ts-ignore
gamepad.vibrationActuator.playEffect("dual-rumble", {
realGamepad.vibrationActuator.playEffect("dual-rumble", {
startDelay: 0,
duration: t * 1000,
weakMagnitude: w,
Expand All @@ -486,6 +597,11 @@
}
}
}

setAxisDeadzone({ DEADZONE }) {
axisDeadzone = Scratch.Cast.toNumber(DEADZONE);
updateState();
}
}

Scratch.extensions.register(new GamepadExtension());
Expand Down

0 comments on commit e746d76

Please sign in to comment.