Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gamepad: Make the deadzone circular and customizable, remember the previous direction #1280

Merged
merged 4 commits into from
Feb 2, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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