feat(input-gamepad): add controller input packages (#2395)

This commit is contained in:
Neko
2026-08-29 03:03:53 +08:00
committed by GitHub
parent 4a18e51660
commit 54da6eac0e
34 changed files with 2170 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# `@proj-airi/input-gamepad`
This package reads controllers through the browser Gamepad API. It accepts controllers that expose the W3C `standard` mapping.
The package converts browser button indices to physical names. For example, button `0` becomes `faceBottom`. The printed label can be `×`, `A`, or `B`.
## Use the package
```ts
import { StandardGamepadMonitor } from '@proj-airi/input-gamepad'
const monitor = new StandardGamepadMonitor({ deadzone: 0.12 })
const stopListening = monitor.onSnapshot((snapshot) => {
if (!snapshot)
return
console.info(snapshot.leftStick, snapshot.buttons.faceBottom)
})
monitor.start()
// Run these operations when the feature closes.
stopListening()
monitor.stop()
```
## When to use it
Use this package directly in framework-independent browser code. It supports buttons, sticks, and analog triggers.
In Vue code, use `@proj-airi/input-gamepad-vueuse`. It owns the polling lifecycle and exposes readonly reactive state.
Do not use this package for motion sensors, touchpads, lights, or adaptive triggers. Use a device-specific adapter for these features.
The package ignores controllers without the `standard` mapping. This rule prevents device-specific button indices from entering application code.
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@proj-airi/input-gamepad",
"type": "module",
"version": "0.12.0-beta.1",
"private": true,
"description": "Standard Gamepad API input for AIRI",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
"url": "https://github.com/moeru-ai"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/input-gamepad"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"build": "tsdown",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {}
}
+18
View File
@@ -0,0 +1,18 @@
export {
createStandardGamepadSnapshot,
detectGamepadFamily,
getGamepadButtonLabel,
isGamepadApiSupported,
StandardGamepadMonitor,
} from './standard-gamepad'
export type {
GamepadFamily,
StandardGamepadButtonName,
StandardGamepadButtonState,
StandardGamepadMonitorOptions,
StandardGamepadSnapshot,
StandardGamepadSnapshotListener,
StandardGamepadSnapshotOptions,
StandardGamepadStickState,
} from './types'
export { standardGamepadButtonNames } from './types'
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest'
import {
createStandardGamepadSnapshot,
detectGamepadFamily,
getGamepadButtonLabel,
StandardGamepadMonitor,
} from './index'
const neutralButton: GamepadButton = Object.freeze({
pressed: false,
touched: false,
value: 0,
})
describe('createStandardGamepadSnapshot', () => {
it('normalizes the standard axes and button positions', () => {
const gamepad = createGamepad({
axes: [0.6, 0, -1, 1],
buttons: {
0: { pressed: true, touched: true, value: 1 },
6: { pressed: true, touched: true, value: 0.75 },
14: { pressed: true, touched: true, value: 1 },
},
id: 'DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)',
})
const snapshot = createStandardGamepadSnapshot(gamepad, { deadzone: 0.2 })
expect(snapshot.family).toBe('playstation')
expect(snapshot.leftStick.x).toBeCloseTo(0.5)
expect(snapshot.leftStick.y).toBe(0)
expect(snapshot.rightStick.x).toBeCloseTo(-Math.SQRT1_2)
expect(snapshot.rightStick.y).toBeCloseTo(Math.SQRT1_2)
expect(snapshot.buttons.faceBottom.pressed).toBe(true)
expect(snapshot.buttons.leftTrigger.value).toBe(0.75)
expect(snapshot.buttons.dpadLeft.pressed).toBe(true)
})
it('rejects devices without the standard mapping', () => {
expect(() => createStandardGamepadSnapshot(createGamepad({ mapping: '' }))).toThrowError(
'The gamepad does not use the standard mapping.',
)
})
})
describe('gamepad labels', () => {
it('detects common controller families', () => {
expect(detectGamepadFamily('Xbox Wireless Controller')).toBe('xbox')
expect(detectGamepadFamily('Nintendo Switch Joy-Con (L/R)')).toBe('nintendo')
expect(detectGamepadFamily('054c DualShock 4')).toBe('playstation')
expect(detectGamepadFamily('Generic USB Gamepad')).toBe('unknown')
})
it('uses printed face labels without changing physical positions', () => {
expect(getGamepadButtonLabel('playstation', 'faceBottom')).toBe('×')
expect(getGamepadButtonLabel('xbox', 'faceBottom')).toBe('A')
expect(getGamepadButtonLabel('nintendo', 'faceBottom')).toBe('B')
expect(getGamepadButtonLabel('playstation', 'faceRight')).toBe('○')
})
})
describe('standardGamepadMonitor', () => {
it('keeps one selected standard gamepad until it disconnects', () => {
let gamepads: readonly (Gamepad | null)[] = [
createGamepad({ connected: true, index: 0, mapping: '' }),
createGamepad({ connected: true, index: 1, id: 'Xbox Wireless Controller' }),
]
let nextFrameId = 0
const frames = new Map<number, FrameRequestCallback>()
const cancelFrame = vi.fn((frameId: number) => frames.delete(frameId))
const monitor = new StandardGamepadMonitor({
cancelFrame,
getGamepads: () => gamepads,
requestFrame(callback) {
const frameId = ++nextFrameId
frames.set(frameId, callback)
return frameId
},
})
const listener = vi.fn()
monitor.onSnapshot(listener)
monitor.start()
runNextFrame(frames)
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1, family: 'xbox' }))
gamepads = [
createGamepad({ connected: true, index: 0, id: 'Nintendo Switch Pro Controller' }),
createGamepad({ connected: true, index: 1, id: 'Xbox Wireless Controller' }),
]
runNextFrame(frames)
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1, family: 'xbox' }))
gamepads = [createGamepad({ connected: true, index: 0, id: 'Nintendo Switch Pro Controller' })]
runNextFrame(frames)
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ index: 0, family: 'nintendo' }))
gamepads = []
runNextFrame(frames)
expect(listener).toHaveBeenLastCalledWith(undefined)
monitor.stop()
expect(cancelFrame).toHaveBeenCalledOnce()
})
})
function createGamepad(options: {
axes?: readonly number[]
buttons?: Readonly<Record<number, GamepadButton>>
connected?: boolean
id?: string
index?: number
mapping?: GamepadMappingType
} = {}): Gamepad {
const buttons = Array.from({ length: 17 }, (_, index): GamepadButton => options.buttons?.[index] ?? neutralButton)
return {
axes: options.axes ?? [0, 0, 0, 0],
buttons,
connected: options.connected ?? true,
id: options.id ?? 'Standard Gamepad',
index: options.index ?? 0,
mapping: options.mapping ?? 'standard',
timestamp: 1,
vibrationActuator: {
playEffect: async () => 'complete',
reset: async () => 'complete',
},
}
}
function runNextFrame(frames: Map<number, FrameRequestCallback>): void {
const next = frames.entries().next().value
if (!next)
throw new Error('The monitor did not schedule a frame.')
const [frameId, callback] = next
frames.delete(frameId)
callback(performance.now())
}
@@ -0,0 +1,288 @@
import type {
GamepadFamily,
StandardGamepadButtonName,
StandardGamepadButtonState,
StandardGamepadMonitorOptions,
StandardGamepadSnapshot,
StandardGamepadSnapshotListener,
StandardGamepadSnapshotOptions,
StandardGamepadStickState,
} from './types'
const standardButtonIndices = Object.freeze({
dpadDown: 13,
dpadLeft: 14,
dpadRight: 15,
dpadUp: 12,
faceBottom: 0,
faceLeft: 2,
faceRight: 1,
faceTop: 3,
leftShoulder: 4,
leftStick: 10,
leftTrigger: 6,
rightShoulder: 5,
rightStick: 11,
rightTrigger: 7,
select: 8,
start: 9,
}) satisfies Readonly<Record<StandardGamepadButtonName, number>>
const neutralButton: StandardGamepadButtonState = Object.freeze({
pressed: false,
touched: false,
value: 0,
})
const familyButtonLabels: Readonly<Record<GamepadFamily, Readonly<Record<StandardGamepadButtonName, string>>>> = {
nintendo: createButtonLabels({
faceBottom: 'B',
faceLeft: 'Y',
faceRight: 'A',
faceTop: 'X',
leftShoulder: 'L',
leftTrigger: 'ZL',
rightShoulder: 'R',
rightTrigger: 'ZR',
}),
playstation: createButtonLabels({
faceBottom: '×',
faceLeft: '□',
faceRight: '○',
faceTop: '△',
leftShoulder: 'L1',
leftTrigger: 'L2',
rightShoulder: 'R1',
rightTrigger: 'R2',
}),
unknown: createButtonLabels({}),
xbox: createButtonLabels({
faceBottom: 'A',
faceLeft: 'X',
faceRight: 'B',
faceTop: 'Y',
leftShoulder: 'LB',
leftTrigger: 'LT',
rightShoulder: 'RB',
rightTrigger: 'RT',
}),
}
/** Returns true when the current browser exposes the Gamepad API. */
export function isGamepadApiSupported(): boolean {
return typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
}
/**
* Infers the printed controller family from a browser gamepad identifier.
* The result changes labels only. It does not change standard button positions.
*
* @example
* detectGamepadFamily('Xbox Wireless Controller')
* // => 'xbox'
*/
export function detectGamepadFamily(id: string): GamepadFamily {
const normalizedId = id.toLowerCase()
if (normalizedId.includes('xbox') || normalizedId.includes('xinput') || normalizedId.includes('045e'))
return 'xbox'
if (normalizedId.includes('nintendo') || normalizedId.includes('joy-con') || normalizedId.includes('057e'))
return 'nintendo'
if (
normalizedId.includes('dualsense')
|| normalizedId.includes('dualshock')
|| normalizedId.includes('playstation')
|| normalizedId.includes('wireless controller')
|| normalizedId.includes('054c')
) {
return 'playstation'
}
return 'unknown'
}
/** Returns the printed label for one physical standard-gamepad button position. */
export function getGamepadButtonLabel(family: GamepadFamily, button: StandardGamepadButtonName): string {
return familyButtonLabels[family][button]
}
/**
* Normalizes a browser gamepad that uses the W3C standard mapping.
*
* @example
* createStandardGamepadSnapshot(gamepad, { deadzone: 0.12 })
* // => { leftStick: { x: 0, y: 0 }, buttons: { ... } }
*/
export function createStandardGamepadSnapshot(
gamepad: Gamepad,
options?: StandardGamepadSnapshotOptions,
): StandardGamepadSnapshot {
if (gamepad.mapping !== 'standard')
throw new Error('The gamepad does not use the standard mapping.')
const deadzone = options?.deadzone ?? 0.12
if (!Number.isFinite(deadzone) || deadzone < 0 || deadzone >= 1)
throw new Error('The gamepad deadzone must be from 0 to less than 1.')
return {
buttons: createButtonStates(gamepad.buttons),
family: detectGamepadFamily(gamepad.id),
id: gamepad.id,
index: gamepad.index,
leftStick: applyRadialDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0, deadzone),
rightStick: applyRadialDeadzone(gamepad.axes[2] ?? 0, gamepad.axes[3] ?? 0, deadzone),
timestamp: gamepad.timestamp,
}
}
/**
* Polls the browser Gamepad API and keeps one standard controller selected.
* The monitor keeps the selected index until that controller disconnects.
*/
export class StandardGamepadMonitor {
readonly #cancelFrame: (handle: number) => void
readonly #deadzone: number | undefined
readonly #getGamepads: () => readonly (Gamepad | null)[]
readonly #listeners = new Set<StandardGamepadSnapshotListener>()
readonly #requestFrame: (callback: FrameRequestCallback) => number
#frameHandle: number | undefined
#latestSnapshot: StandardGamepadSnapshot | undefined
#selectedIndex: number | undefined
constructor(options?: StandardGamepadMonitorOptions) {
this.#cancelFrame = options?.cancelFrame ?? (handle => cancelAnimationFrame(handle))
this.#deadzone = options?.deadzone
this.#getGamepads = options?.getGamepads ?? (() => {
if (!isGamepadApiSupported())
throw new Error('The Gamepad API is not available in this browser.')
return navigator.getGamepads()
})
this.#requestFrame = options?.requestFrame ?? (callback => requestAnimationFrame(callback))
}
/** The latest connected snapshot. This value remains available after `stop()`. */
get latestSnapshot(): StandardGamepadSnapshot | undefined {
return this.#latestSnapshot
}
/** Returns true while the monitor owns a scheduled animation frame. */
get running(): boolean {
return this.#frameHandle !== undefined
}
/** Adds a snapshot listener and returns its cleanup function. */
onSnapshot(listener: StandardGamepadSnapshotListener): () => void {
this.#listeners.add(listener)
return () => this.#listeners.delete(listener)
}
/** Starts polling. Repeated calls do not create duplicate loops. */
start(): void {
if (this.#frameHandle !== undefined)
return
this.#frameHandle = this.#requestFrame(this.#poll)
}
/** Stops polling. The latest snapshot remains available for diagnostics. */
stop(): void {
if (this.#frameHandle === undefined)
return
this.#cancelFrame(this.#frameHandle)
this.#frameHandle = undefined
this.#selectedIndex = undefined
}
readonly #poll = (): void => {
this.#frameHandle = this.#requestFrame(this.#poll)
const gamepads = this.#getGamepads()
const selected = this.#findSelectedGamepad(gamepads)
if (!selected) {
if (this.#latestSnapshot) {
this.#latestSnapshot = undefined
this.#emit(undefined)
}
return
}
const snapshot = createStandardGamepadSnapshot(selected, { deadzone: this.#deadzone })
this.#latestSnapshot = snapshot
this.#emit(snapshot)
}
#emit(snapshot: StandardGamepadSnapshot | undefined): void {
for (const listener of this.#listeners)
listener(snapshot)
}
#findSelectedGamepad(gamepads: readonly (Gamepad | null)[]): Gamepad | undefined {
if (this.#selectedIndex !== undefined) {
const selected = gamepads.find(gamepad => gamepad?.index === this.#selectedIndex)
if (selected?.connected && selected.mapping === 'standard')
return selected
}
const next = gamepads.find(gamepad => gamepad?.connected && gamepad.mapping === 'standard') ?? undefined
this.#selectedIndex = next?.index
return next
}
}
function applyRadialDeadzone(x: number, y: number, deadzone: number): StandardGamepadStickState {
const clampedX = clampAxis(x)
const clampedY = clampAxis(y)
const inputMagnitude = Math.hypot(clampedX, clampedY)
if (inputMagnitude <= deadzone)
return { x: 0, y: 0 }
const magnitude = Math.min(1, inputMagnitude)
const outputMagnitude = (magnitude - deadzone) / (1 - deadzone)
const scale = outputMagnitude / inputMagnitude
return {
x: clampedX * scale,
y: clampedY * scale,
}
}
function clampAxis(value: number): number {
if (!Number.isFinite(value))
return 0
return Math.min(1, Math.max(-1, value))
}
function createButtonStates(buttons: ReadonlyArray<GamepadButton>): Readonly<Record<StandardGamepadButtonName, StandardGamepadButtonState>> {
return Object.fromEntries(
Object.entries(standardButtonIndices).map(([name, index]) => [name, readButton(buttons[index])]),
) as Record<StandardGamepadButtonName, StandardGamepadButtonState>
}
function readButton(button: GamepadButton | undefined): StandardGamepadButtonState {
if (!button)
return neutralButton
return {
pressed: button.pressed,
touched: button.touched,
value: Math.min(1, Math.max(0, button.value)),
}
}
function createButtonLabels(
overrides: Partial<Record<StandardGamepadButtonName, string>>,
): Readonly<Record<StandardGamepadButtonName, string>> {
return {
dpadDown: 'D-pad ↓',
dpadLeft: 'D-pad ←',
dpadRight: 'D-pad →',
dpadUp: 'D-pad ↑',
faceBottom: 'Bottom',
faceLeft: 'Left',
faceRight: 'Right',
faceTop: 'Top',
leftShoulder: 'LB',
leftStick: 'LS',
leftTrigger: 'LT',
rightShoulder: 'RB',
rightStick: 'RS',
rightTrigger: 'RT',
select: 'Select',
start: 'Start',
...overrides,
}
}
+71
View File
@@ -0,0 +1,71 @@
/** A controller family inferred from the browser gamepad identifier. */
export type GamepadFamily = 'nintendo' | 'playstation' | 'unknown' | 'xbox'
/** Physical button positions in the W3C standard gamepad layout. */
export const standardGamepadButtonNames = [
'dpadDown',
'dpadLeft',
'dpadRight',
'dpadUp',
'faceBottom',
'faceLeft',
'faceRight',
'faceTop',
'leftShoulder',
'leftStick',
'leftTrigger',
'rightShoulder',
'rightStick',
'rightTrigger',
'select',
'start',
] as const
/** A physical button position in the W3C standard gamepad layout. */
export type StandardGamepadButtonName = typeof standardGamepadButtonNames[number]
/** One browser button value in a standard gamepad snapshot. */
export interface StandardGamepadButtonState {
readonly pressed: boolean
readonly touched: boolean
/** The analog button value in the range from 0 to 1. */
readonly value: number
}
/** One normalized thumbstick. Both axes use the range from -1 to 1. */
export interface StandardGamepadStickState {
readonly x: number
/** Positive values point down, as defined by the Gamepad API. */
readonly y: number
}
/** A normalized snapshot from a browser gamepad with the `standard` mapping. */
export interface StandardGamepadSnapshot {
readonly buttons: Readonly<Record<StandardGamepadButtonName, StandardGamepadButtonState>>
readonly family: GamepadFamily
readonly id: string
readonly index: number
readonly leftStick: StandardGamepadStickState
readonly rightStick: StandardGamepadStickState
/** The browser timestamp for the source gamepad state. */
readonly timestamp: number
}
/** Options for standard gamepad normalization. */
export interface StandardGamepadSnapshotOptions {
/** Radial thumbstick deadzone in the range from 0 to less than 1. @default 0.12 */
readonly deadzone?: number
}
/** Runtime dependencies and normalization settings for {@link StandardGamepadMonitor}. */
export interface StandardGamepadMonitorOptions extends StandardGamepadSnapshotOptions {
/** Cancels one scheduled frame. @default cancelAnimationFrame */
readonly cancelFrame?: (handle: number) => void
/** Returns the current browser gamepad slots. @default navigator.getGamepads */
readonly getGamepads?: () => readonly (Gamepad | null)[]
/** Schedules the next input sample. @default requestAnimationFrame */
readonly requestFrame?: (callback: FrameRequestCallback) => number
}
/** Receives the selected controller state, or `undefined` after it disconnects. */
export type StandardGamepadSnapshotListener = (snapshot: StandardGamepadSnapshot | undefined) => void
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
]
},
"include": [
"src/**/*.ts",
"tsdown.config.ts",
"vitest.config.ts"
],
"exclude": [
"dist",
"node_modules"
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
dts: true,
platform: 'browser',
})
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})