@threlte/extras
Version:
Utilities, abstractions and plugins for your Threlte apps
262 lines (261 loc) • 10.1 kB
JavaScript
/**
* Controller remapping support for `useGamepad`.
*
* The browser's Gamepad API only guarantees a consistent layout when
* `Gamepad.mapping === "standard"`. Many controllers — notably Nintendo Switch
* Pro, Joy-Cons, and the Switch Online SNES/N64/Genesis pads — commonly report
* an empty `mapping` string, which means the raw button and axis indices come
* straight from the HID descriptor and do not match the W3C Standard Gamepad
* layout. This module provides a small, extensible table of controller-specific
* remaps so `useGamepad` can expose a consistent position-based API regardless
* of controller brand.
*
* The name of each button in the standard layout (`clusterBottom`, `clusterRight`,
* `clusterLeft`, `clusterTop`) refers to the physical position of the face
* button, not the letter printed on it. So on an Xbox pad `clusterBottom === A`,
* on a DualSense `clusterBottom === Cross`, and on a Nintendo pad
* `clusterBottom === B`.
*/
/**
* The standard button names exposed by `useGamepad` in non-XR mode, in the
* order of the W3C Standard Gamepad button indices.
*/
export const standardButtonNames = [
'clusterBottom',
'clusterRight',
'clusterLeft',
'clusterTop',
'leftBumper',
'rightBumper',
'leftTrigger',
'rightTrigger',
'select',
'start',
'leftStickButton',
'rightStickButton',
'directionalTop',
'directionalBottom',
'directionalLeft',
'directionalRight',
'center'
];
/**
* Parse a `Gamepad.id` string into a canonical `vvvv:pppp` signature.
*
* Chromium exposes `"Name (Vendor: VVVV Product: PPPP)"`, Firefox exposes
* `"VVVV-PPPP-Name"`. Returns `null` if neither pattern matches, in which
* case the caller cannot look up a remap and should fall back to the
* standard layout.
*/
export const parseGamepadSignature = (id) => {
const chromeMatch = id.match(/Vendor:\s*([0-9a-f]{4}).*?Product:\s*([0-9a-f]{4})/i);
if (chromeMatch)
return `${chromeMatch[1].toLowerCase()}:${chromeMatch[2].toLowerCase()}`;
const firefoxMatch = id.match(/^([0-9a-f]{4})-([0-9a-f]{4})/i);
if (firefoxMatch)
return `${firefoxMatch[1].toLowerCase()}:${firefoxMatch[2].toLowerCase()}`;
return null;
};
/**
* Hat values reported on `axes[9]` by Chromium browsers for controllers
* that use the W3C "POV hat" convention. Values go clockwise from North (-1.0)
* in steps of 2/7 ≈ 0.2857; idle is any value outside the match tolerance
* (commonly ~3.28 on Chromium, but we just don't match any direction there).
*/
const chromiumHat = {
axis: 9,
up: [-1.0, -0.7143, 1.0],
right: [-0.7143, -0.4286, -0.1429],
down: [-0.1429, 0.1429, 0.4286],
left: [0.4286, 0.7143, 1.0]
};
/**
* Built-in mappings for common non-standard controllers.
*
* Every browser/OS/connection combination can potentially report a controller
* differently, so these entries target the most widely reported configurations
* (Chromium browsers on recent macOS/Windows/Linux, USB and Bluetooth).
* Users with different setups can override any entry by passing `mappings` to
* `useGamepad`.
*
* Sources:
* - W3C Gamepad Standard Mapping (https://www.w3.org/TR/gamepad/#remapping)
* - SDL_GameControllerDB (https://github.com/mdqinc/SDL_GameControllerDB)
* - Chromium `device/gamepad/gamepad_standard_mappings_*.cc` (per-platform
* remapping tables shipped inside the browser itself)
*/
export const builtinMappings = {
/**
* Nintendo Switch Pro Controller
*
* Vendor 057e. Reports `mapping: ""` on Chromium
* when connected over Bluetooth on macOS, and on Chromium versions that
* predate the platform-specific Nintendo remap table. Layout below follows
* the HID report descriptor order used by Chromium's native mapping code:
*
* buttons: B A Y X L R ZL ZR - + Lstick Rstick Home Capture
* axes: leftX leftY rightX rightY [unused ...] hat
*
* Position-based names in Threlte's API map as follows:
*
* clusterBottom (south) = B = index 0
* clusterRight (east) = A = index 1
* clusterLeft (west) = Y = index 2
* clusterTop (north) = X = index 3
*/
'057e:2009': {
name: 'Nintendo Switch Pro Controller',
buttons: {
clusterBottom: { button: 0 },
clusterRight: { button: 1 },
clusterLeft: { button: 2 },
clusterTop: { button: 3 },
leftBumper: { button: 4 },
rightBumper: { button: 5 },
leftTrigger: { button: 6 },
rightTrigger: { button: 7 },
select: { button: 8 },
start: { button: 9 },
leftStickButton: { button: 10 },
rightStickButton: { button: 11 },
center: { button: 12 }
},
leftStick: { xAxis: 0, yAxis: 1 },
rightStick: { xAxis: 2, yAxis: 3 },
dpad: chromiumHat
},
/**
* Nintendo Switch Online SNES Controller
*
* Vendor 057e. The front face has the classic SNES layout
* (Y X / B A + d-pad + Select + Start + L + R shoulders) and the back
* adds two small ZL / ZR triggers above the shoulder buttons.
*
* Button layout was validated against a real controller over Bluetooth on
* macOS / Chrome (16 buttons, 10 axes, `mapping: ""`). Notable quirk: ZR
* reports at index 15, not next to ZL at index 6/7 as the W3C standard
* would suggest — the HID report descriptor on this pad places ZR after
* several unused button slots. The analog sticks and stick buttons don't
* exist on this controller, so those entries are intentionally omitted.
*/
'057e:2017': {
name: 'Nintendo Switch Online SNES Controller',
buttons: {
clusterBottom: { button: 0 },
clusterRight: { button: 1 },
clusterLeft: { button: 2 },
clusterTop: { button: 3 },
leftBumper: { button: 4 },
rightBumper: { button: 5 },
leftTrigger: { button: 6 },
rightTrigger: { button: 15 },
select: { button: 8 },
start: { button: 9 },
center: { button: 12 }
},
dpad: chromiumHat
},
/**
* Nintendo Joy-Con (L)
*
* Vendor 057e. Used on its own (sideways) the Joy-Con exposes
* its arrow pad as four face buttons rather than a D-pad. Sticks are mapped
* to the left stick only; there is no second analog stick.
*/
'057e:2006': {
name: 'Nintendo Joy-Con (L)',
buttons: {
clusterBottom: { button: 0 },
clusterRight: { button: 1 },
clusterLeft: { button: 2 },
clusterTop: { button: 3 },
leftBumper: { button: 4 },
rightBumper: { button: 5 },
select: { button: 8 },
start: { button: 9 },
leftStickButton: { button: 10 },
center: { button: 12 }
},
leftStick: { xAxis: 0, yAxis: 1 }
},
/**
* Nintendo Joy-Con (R)
*
* Vendor 057e. Mirrors the (L) layout on the other hand.
*/
'057e:2007': {
name: 'Nintendo Joy-Con (R)',
buttons: {
clusterBottom: { button: 0 },
clusterRight: { button: 1 },
clusterLeft: { button: 2 },
clusterTop: { button: 3 },
leftBumper: { button: 4 },
rightBumper: { button: 5 },
select: { button: 8 },
start: { button: 9 },
rightStickButton: { button: 11 },
center: { button: 12 }
},
rightStick: { xAxis: 0, yAxis: 1 }
}
};
/**
* Look up a remap for the given `Gamepad`. Returns `null` when the pad already
* uses the Standard Gamepad layout, when its id cannot be parsed into a
* vendor/product signature, or when no mapping is registered for that pad.
*/
export const resolveMapping = (pad, userMappings, includeBuiltins) => {
if (pad.mapping === 'standard')
return null;
const signature = parseGamepadSignature(pad.id);
if (!signature)
return null;
return (userMappings?.[signature] ?? (includeBuiltins ? builtinMappings[signature] : undefined) ?? null);
};
/**
* Read a single button's state using an optional remap entry. Falls back to
* `pad.buttons[defaultIndex]` when no remap is present. The synthesised return
* for axis-sourced buttons is structurally compatible with `GamepadButton`.
*/
export const readButton = (pad, source, defaultIndex) => {
if (!source)
return pad.buttons[defaultIndex];
if ('button' in source)
return pad.buttons[source.button];
const raw = pad.axes[source.axis] ?? 0;
const signed = source.invert ? -raw : raw;
const normalised = source.range === 'signed' ? (signed + 1) / 2 : signed;
const value = Math.min(1, Math.max(0, normalised));
const threshold = source.pressThreshold ?? 0.5;
return {
pressed: value >= threshold,
touched: value > 0,
value
};
};
/**
* Read a stick's (x, y) using an optional remap. Falls back to the supplied
* default axis indices and no inversion when no remap is present.
*/
export const readStick = (pad, mapping, defaultXAxis, defaultYAxis) => {
const xAxis = mapping?.xAxis ?? defaultXAxis;
const yAxis = mapping?.yAxis ?? defaultYAxis;
const x = (pad.axes[xAxis] ?? 0) * (mapping?.invertX ? -1 : 1);
const y = (pad.axes[yAxis] ?? 0) * (mapping?.invertY ? -1 : 1);
return { x, y };
};
/** Compute the four directional states from a hat-axis mapping. */
export const readHatDirections = (pad, hat) => {
const v = pad.axes[hat.axis];
if (v === undefined)
return { up: false, right: false, down: false, left: false };
const tol = hat.tolerance ?? 0.1;
const matches = (targets) => targets.some((t) => Math.abs(v - t) < tol);
return {
up: matches(hat.up),
right: matches(hat.right),
down: matches(hat.down),
left: matches(hat.left)
};
};