feat(input-gamepad): add controller input packages (#2395)
This commit is contained in:
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
dts: true,
|
||||
platform: 'browser',
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user