diff --git a/packages/input-gamepad-vueuse/README.md b/packages/input-gamepad-vueuse/README.md new file mode 100644 index 000000000..9c2f9025b --- /dev/null +++ b/packages/input-gamepad-vueuse/README.md @@ -0,0 +1,38 @@ +# `@proj-airi/input-gamepad-vueuse` + +This package provides Vue composables for `@proj-airi/input-gamepad`. +It owns the monitor lifecycle and exposes readonly reactive controller state. + +## Use the package + +```ts +import { useStandardGamepad } from '@proj-airi/input-gamepad-vueuse' +import { whenever } from '@vueuse/core' +import { watchEffect } from 'vue' + +const gamepad = useStandardGamepad() + +whenever(gamepad.buttons.faceBottom, () => { + console.info('The bottom face button is pressed.') +}) + +whenever(gamepad.pressed('leftShoulder', 'dpadLeft'), () => { + console.info('The shortcut is pressed.') +}) + +watchEffect(() => { + console.info(gamepad.sticks.left.value, gamepad.values.rightTrigger.value) +}) +``` + +The composable starts after its Vue scope mounts. It stops when that scope is disposed. +Use `pause()` and `resume()` when a mounted feature must stop input temporarily. + +## When to use it + +Use this package in Vue browser and Electron renderer code. +Use `@proj-airi/input-gamepad` in framework-independent code. + +This package exposes standard buttons, sticks, and analog triggers. +It does not expose motion sensors, touchpads, lights, or adaptive triggers. +Use a device-specific package for these features. diff --git a/packages/input-gamepad-vueuse/package.json b/packages/input-gamepad-vueuse/package.json new file mode 100644 index 000000000..4f31fbde6 --- /dev/null +++ b/packages/input-gamepad-vueuse/package.json @@ -0,0 +1,46 @@ +{ + "name": "@proj-airi/input-gamepad-vueuse", + "type": "module", + "version": "0.12.0-beta.1", + "private": true, + "description": "Vue composables for AIRI gamepad input", + "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-vueuse" + }, + "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" + }, + "peerDependencies": { + "vue": ">=3.5" + }, + "dependencies": { + "@proj-airi/input-gamepad": "workspace:^", + "@vueuse/core": "catalog:" + }, + "devDependencies": { + "vue": "catalog:" + } +} diff --git a/packages/input-gamepad-vueuse/src/index.test.ts b/packages/input-gamepad-vueuse/src/index.test.ts new file mode 100644 index 000000000..f2e340087 --- /dev/null +++ b/packages/input-gamepad-vueuse/src/index.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' + +import { useStandardGamepad } from './index' + +const neutralButton: GamepadButton = Object.freeze({ + pressed: false, + touched: false, + value: 0, +}) + +describe('useStandardGamepad', () => { + it('exposes reactive buttons, values, sticks, and combinations', () => { + let gamepads: readonly (Gamepad | null)[] = [createGamepad({ + axes: [0.6, 0, 0, 0], + buttons: { + 0: { pressed: true, touched: true, value: 1 }, + 4: { pressed: true, touched: true, value: 1 }, + 6: { pressed: true, touched: true, value: 0.75 }, + }, + })] + const frames = new Map() + let nextFrameId = 0 + const cancelFrame = vi.fn((frameId: number) => frames.delete(frameId)) + const scope = effectScope() + const gamepad = scope.run(() => useStandardGamepad({ + cancelFrame, + getGamepads: () => gamepads, + requestFrame(callback) { + const frameId = ++nextFrameId + frames.set(frameId, callback) + return frameId + }, + })) + + if (!gamepad) + throw new Error('The composable did not start in the effect scope.') + + const shortcut = gamepad.pressed('leftShoulder', 'faceBottom') + expect(gamepad.isSupported.value).toBe(true) + expect(gamepad.isActive.value).toBe(true) + + runNextFrame(frames) + + expect(gamepad.isConnected.value).toBe(true) + expect(gamepad.family.value).toBe('playstation') + expect(gamepad.buttons.faceBottom.value).toBe(true) + expect(gamepad.values.leftTrigger.value).toBe(0.75) + expect(gamepad.sticks.left.value.x).toBeCloseTo(0.545) + expect(shortcut.value).toBe(true) + + gamepads = [] + runNextFrame(frames) + + expect(gamepad.isConnected.value).toBe(false) + expect(gamepad.buttons.faceBottom.value).toBe(false) + expect(gamepad.values.leftTrigger.value).toBe(0) + expect(shortcut.value).toBe(false) + + scope.stop() + expect(cancelFrame).toHaveBeenCalledOnce() + expect(gamepad.isActive.value).toBe(false) + }) + + it('pauses and resumes the polling loop', () => { + const frames = new Map() + let nextFrameId = 0 + const cancelFrame = vi.fn((frameId: number) => frames.delete(frameId)) + const scope = effectScope() + const gamepad = scope.run(() => useStandardGamepad({ + cancelFrame, + getGamepads: () => [], + requestFrame(callback) { + const frameId = ++nextFrameId + frames.set(frameId, callback) + return frameId + }, + })) + + if (!gamepad) + throw new Error('The composable did not start in the effect scope.') + + gamepad.pause() + expect(gamepad.isActive.value).toBe(false) + expect(cancelFrame).toHaveBeenCalledOnce() + + gamepad.resume() + expect(gamepad.isActive.value).toBe(true) + expect(frames.size).toBe(1) + + scope.stop() + expect(cancelFrame).toHaveBeenCalledTimes(2) + }) +}) + +function createGamepad(options: { + axes?: readonly number[] + buttons?: Readonly>> +} = {}): Gamepad { + const buttons = Array.from({ length: 17 }, (_, index): GamepadButton => options.buttons?.[index] ?? neutralButton) + + return { + axes: options.axes ?? [0, 0, 0, 0], + buttons, + connected: true, + id: 'DualSense Wireless Controller', + index: 0, + mapping: 'standard', + timestamp: 1, + vibrationActuator: { + playEffect: async () => 'complete', + reset: async () => 'complete', + }, + } +} + +function runNextFrame(frames: Map): 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()) +} diff --git a/packages/input-gamepad-vueuse/src/index.ts b/packages/input-gamepad-vueuse/src/index.ts new file mode 100644 index 000000000..fe1201040 --- /dev/null +++ b/packages/input-gamepad-vueuse/src/index.ts @@ -0,0 +1,6 @@ +export { useStandardGamepad } from './use-standard-gamepad' +export type { + StandardGamepadButtonRefs, + StandardGamepadValueRefs, + UseStandardGamepadReturn, +} from './use-standard-gamepad' diff --git a/packages/input-gamepad-vueuse/src/use-standard-gamepad.ts b/packages/input-gamepad-vueuse/src/use-standard-gamepad.ts new file mode 100644 index 000000000..7496f7a10 --- /dev/null +++ b/packages/input-gamepad-vueuse/src/use-standard-gamepad.ts @@ -0,0 +1,134 @@ +import type { + GamepadFamily, + StandardGamepadButtonName, + StandardGamepadMonitorOptions, + StandardGamepadSnapshot, + StandardGamepadStickState, +} from '@proj-airi/input-gamepad' +import type { ComputedRef, DeepReadonly, ShallowRef } from 'vue' + +import { + isGamepadApiSupported, + standardGamepadButtonNames, + StandardGamepadMonitor, +} from '@proj-airi/input-gamepad' +import { tryOnMounted, tryOnScopeDispose } from '@vueuse/core' +import { computed, readonly, shallowRef } from 'vue' + +const neutralStick: StandardGamepadStickState = Object.freeze({ x: 0, y: 0 }) + +/** Reactive pressed states for all standard gamepad buttons. */ +export type StandardGamepadButtonRefs = Readonly>> + +/** Reactive analog values for all standard gamepad buttons. */ +export type StandardGamepadValueRefs = Readonly>> + +/** Reactive state and lifecycle controls for one selected standard gamepad. */ +export interface UseStandardGamepadReturn { + /** True when the selected gamepad is connected. */ + readonly isConnected: ComputedRef + /** True while the composable owns a polling loop. */ + readonly isActive: DeepReadonly> + /** True when the Gamepad API or an injected reader is available. */ + readonly isSupported: ComputedRef + /** The controller family used for printed button labels. */ + readonly family: ComputedRef + /** The latest normalized state. */ + readonly snapshot: DeepReadonly> + /** Digital pressed states by physical button position. */ + readonly buttons: StandardGamepadButtonRefs + /** Analog values by physical button position. */ + readonly values: StandardGamepadValueRefs + /** Normalized thumbstick positions. */ + readonly sticks: Readonly<{ + left: ComputedRef + right: ComputedRef + }> + /** Pauses the polling loop. The latest snapshot remains available. */ + readonly pause: () => void + /** Returns a reactive state that is true while all specified buttons are pressed. */ + readonly pressed: (...buttons: StandardGamepadButtonName[]) => ComputedRef + /** Starts the polling loop when the Gamepad API is available. */ + readonly resume: () => void +} + +/** + * Provides reactive state for one browser gamepad with the W3C standard mapping. + * + * The composable owns its monitor until the current Vue scope is disposed. + * Use the dependency options only for an alternate browser runtime or a test adapter. + */ +export function useStandardGamepad(options?: StandardGamepadMonitorOptions): UseStandardGamepadReturn { + const snapshot = shallowRef() + const isActive = shallowRef(false) + const monitor = new StandardGamepadMonitor(options) + const stopListening = monitor.onSnapshot((nextSnapshot) => { + snapshot.value = nextSnapshot + }) + + const isSupported = computed(() => options?.getGamepads !== undefined || isGamepadApiSupported()) + const isConnected = computed(() => snapshot.value !== undefined) + const family = computed(() => snapshot.value?.family ?? 'unknown') + const buttons = createButtonRefs(snapshot) + const values = createValueRefs(snapshot) + const sticks = Object.freeze({ + left: computed(() => snapshot.value?.leftStick ?? neutralStick), + right: computed(() => snapshot.value?.rightStick ?? neutralStick), + }) + + function pause(): void { + if (!isActive.value) + return + + monitor.stop() + isActive.value = false + } + + function resume(): void { + if (!isSupported.value || isActive.value) + return + + monitor.start() + isActive.value = true + } + + function pressed(...buttonNames: StandardGamepadButtonName[]): ComputedRef { + return computed(() => buttonNames.length > 0 && buttonNames.every(name => buttons[name].value)) + } + + tryOnMounted(resume) + tryOnScopeDispose(() => { + stopListening() + pause() + }) + + return { + buttons, + family, + isActive: readonly(isActive), + isConnected, + isSupported, + pause, + pressed, + resume, + snapshot: readonly(snapshot), + sticks, + values, + } +} + +function createButtonRefs( + snapshot: Readonly>, +): StandardGamepadButtonRefs { + return Object.freeze(Object.fromEntries( + standardGamepadButtonNames.map(name => [name, computed(() => snapshot.value?.buttons[name].pressed ?? false)]), + )) as StandardGamepadButtonRefs +} + +function createValueRefs( + snapshot: Readonly>, +): StandardGamepadValueRefs { + return Object.freeze(Object.fromEntries( + standardGamepadButtonNames.map(name => [name, computed(() => snapshot.value?.buttons[name].value ?? 0)]), + )) as StandardGamepadValueRefs +} diff --git a/packages/input-gamepad-vueuse/tsconfig.json b/packages/input-gamepad-vueuse/tsconfig.json new file mode 100644 index 000000000..e159b9887 --- /dev/null +++ b/packages/input-gamepad-vueuse/tsconfig.json @@ -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" + ] +} diff --git a/packages/input-gamepad-vueuse/tsdown.config.ts b/packages/input-gamepad-vueuse/tsdown.config.ts new file mode 100644 index 000000000..52614d38f --- /dev/null +++ b/packages/input-gamepad-vueuse/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + platform: 'browser', +}) diff --git a/packages/input-gamepad-vueuse/vitest.config.ts b/packages/input-gamepad-vueuse/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/input-gamepad-vueuse/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/input-gamepad/README.md b/packages/input-gamepad/README.md new file mode 100644 index 000000000..01d1757ad --- /dev/null +++ b/packages/input-gamepad/README.md @@ -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. diff --git a/packages/input-gamepad/package.json b/packages/input-gamepad/package.json new file mode 100644 index 000000000..52738cb25 --- /dev/null +++ b/packages/input-gamepad/package.json @@ -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": {} +} diff --git a/packages/input-gamepad/src/index.ts b/packages/input-gamepad/src/index.ts new file mode 100644 index 000000000..d42f3c22c --- /dev/null +++ b/packages/input-gamepad/src/index.ts @@ -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' diff --git a/packages/input-gamepad/src/standard-gamepad.test.ts b/packages/input-gamepad/src/standard-gamepad.test.ts new file mode 100644 index 000000000..cde540411 --- /dev/null +++ b/packages/input-gamepad/src/standard-gamepad.test.ts @@ -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() + 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> + 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): 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()) +} diff --git a/packages/input-gamepad/src/standard-gamepad.ts b/packages/input-gamepad/src/standard-gamepad.ts new file mode 100644 index 000000000..d33dd6285 --- /dev/null +++ b/packages/input-gamepad/src/standard-gamepad.ts @@ -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> + +const neutralButton: StandardGamepadButtonState = Object.freeze({ + pressed: false, + touched: false, + value: 0, +}) + +const familyButtonLabels: Readonly>>> = { + 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() + 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): Readonly> { + return Object.fromEntries( + Object.entries(standardButtonIndices).map(([name, index]) => [name, readButton(buttons[index])]), + ) as Record +} + +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>, +): Readonly> { + 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, + } +} diff --git a/packages/input-gamepad/src/types.ts b/packages/input-gamepad/src/types.ts new file mode 100644 index 000000000..e82a45281 --- /dev/null +++ b/packages/input-gamepad/src/types.ts @@ -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> + 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 diff --git a/packages/input-gamepad/tsconfig.json b/packages/input-gamepad/tsconfig.json new file mode 100644 index 000000000..e159b9887 --- /dev/null +++ b/packages/input-gamepad/tsconfig.json @@ -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" + ] +} diff --git a/packages/input-gamepad/tsdown.config.ts b/packages/input-gamepad/tsdown.config.ts new file mode 100644 index 000000000..52614d38f --- /dev/null +++ b/packages/input-gamepad/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + platform: 'browser', +}) diff --git a/packages/input-gamepad/vitest.config.ts b/packages/input-gamepad/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/input-gamepad/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/input-playstation-dualsense-5/README.md b/packages/input-playstation-dualsense-5/README.md new file mode 100644 index 000000000..c9a5937e1 --- /dev/null +++ b/packages/input-playstation-dualsense-5/README.md @@ -0,0 +1,51 @@ +# `@proj-airi/input-playstation-dualsense-5` + +This package reads and writes Sony PlayStation 5 DualSense reports through WebHID. It has no UI code. + +The package supports the standard DualSense controller with product ID `0x0ce6`. It supports USB and Bluetooth reports. + +## Use the package + +Call `requestDualSenseDevice()` from a user action. WebHID requires a user action before it shows the device chooser. + +```ts +import { + createDefaultDualSenseOutputState, + DualSenseController, + requestDualSenseDevice, +} from '@proj-airi/input-playstation-dualsense-5' + +const device = await requestDualSenseDevice() +if (!device) + throw new Error('No DualSense device was selected.') + +const controller = new DualSenseController(device) +const stopInput = controller.onInputReport((report) => { + console.info(report.state.sticks.left) +}) + +await controller.open() + +const output = createDefaultDualSenseOutputState() +await controller.sendOutput({ + ...output, + lightbar: { red: 124, green: 178, blue: 232 }, +}) + +stopInput() +await controller.close() +``` + +Use `getGrantedDualSenseDevices()` to find devices that already have permission. Use `onDualSenseConnectionChange()` to observe connect and disconnect events. + +## When to use it + +Use this package when a Chromium renderer needs raw DualSense input, motion sensors, touch points, LEDs, rumble, or adaptive triggers. + +Do not use this package for a generic gamepad. Use the Gamepad API when standard buttons and axes are sufficient. + +WebHID requires a secure context. The host browser must support WebHID. + +## Reference + +- https://github.com/nondebug/dualsense diff --git a/packages/input-playstation-dualsense-5/package.json b/packages/input-playstation-dualsense-5/package.json new file mode 100644 index 000000000..1f47462dc --- /dev/null +++ b/packages/input-playstation-dualsense-5/package.json @@ -0,0 +1,40 @@ +{ + "name": "@proj-airi/input-playstation-dualsense-5", + "type": "module", + "version": "0.12.0-beta.1", + "private": true, + "description": "WebHID input and output support for the PlayStation 5 DualSense controller", + "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-playstation-dualsense-5" + }, + "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": { + "@types/w3c-web-hid": "catalog:" + }, + "devDependencies": {} +} diff --git a/packages/input-playstation-dualsense-5/src/controller.ts b/packages/input-playstation-dualsense-5/src/controller.ts new file mode 100644 index 000000000..0e4b50c76 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/controller.ts @@ -0,0 +1,144 @@ +import type { + DualSenseConnectionType, + DualSenseControllerLifecycle, + DualSenseInputReport, + DualSenseInputReportListener, + DualSenseOutputState, +} from './types' + +import { parseDualSenseInputReport } from './input-report' +import { buildDualSenseOutputReport } from './output-report' +import { + detectDualSenseConnectionType, + isDualSenseDevice, +} from './web-hid' + +/** + * Owns one DualSense WebHID device and converts its raw reports to typed state. + * The controller does not schedule output reports. The caller controls the send rate. + */ +export class DualSenseController { + readonly #connectionType: DualSenseConnectionType + readonly #device: HIDDevice + readonly #inputReportListeners = new Set() + #latestInputReport: DualSenseInputReport | undefined + #lifecycle: DualSenseControllerLifecycle = 'closed' + #outputSequenceNumber = 1 + + constructor(device: HIDDevice) { + if (!isDualSenseDevice(device)) + throw new Error('The HID device is not a standard DualSense controller.') + + this.#device = device + this.#connectionType = detectDualSenseConnectionType(device) + } + + /** The transport detected from the HID report descriptor. */ + get connectionType(): DualSenseConnectionType { + return this.#connectionType + } + + /** The WebHID device owned by this controller. */ + get device(): HIDDevice { + return this.#device + } + + /** The most recent parsed report. This value remains available after `close()`. */ + get latestInputReport(): DualSenseInputReport | undefined { + return this.#latestInputReport + } + + /** The current controller lifecycle state. */ + get lifecycle(): DualSenseControllerLifecycle { + return this.#lifecycle + } + + /** + * Opens the HID device and starts input report handling. + * Bluetooth controllers also receive feature report `0x05` to enable extended reports. + */ + async open(): Promise { + if (this.#lifecycle === 'open') + return + if (this.#lifecycle !== 'closed') + throw new Error(`Cannot open a DualSense controller while it is ${this.#lifecycle}.`) + + this.#lifecycle = 'opening' + try { + if (!this.#device.opened) + await this.#device.open() + if (this.#connectionType === 'bluetooth') + await this.#device.receiveFeatureReport(0x05) + this.#device.addEventListener('inputreport', this.#handleInputReport) + this.#lifecycle = 'open' + } + catch (openError) { + this.#device.removeEventListener('inputreport', this.#handleInputReport) + this.#lifecycle = 'closed' + if (!this.#device.opened) + throw openError + + try { + await this.#device.close() + } + catch (closeError) { + throw new AggregateError( + [openError, closeError], + 'The DualSense controller failed to open and close.', + ) + } + throw openError + } + } + + /** Stops input report handling and closes the HID device. */ + async close(): Promise { + if (this.#lifecycle === 'closed') + return + if (this.#lifecycle !== 'open') + throw new Error(`Cannot close a DualSense controller while it is ${this.#lifecycle}.`) + + this.#lifecycle = 'closing' + this.#device.removeEventListener('inputreport', this.#handleInputReport) + try { + if (this.#device.opened) + await this.#device.close() + } + finally { + this.#lifecycle = 'closed' + } + } + + /** Adds an input report listener and returns its cleanup function. */ + onInputReport(listener: DualSenseInputReportListener): () => void { + this.#inputReportListeners.add(listener) + return () => this.#inputReportListeners.delete(listener) + } + + /** Builds and sends one output report with the specified controller state. */ + async sendOutput(output: DualSenseOutputState): Promise { + if (this.#lifecycle !== 'open') + throw new Error('Open the DualSense controller before you send an output report.') + + const report = buildDualSenseOutputReport( + this.#connectionType, + output, + this.#outputSequenceNumber, + ) + this.#outputSequenceNumber = report.nextSequenceNumber + await this.#device.sendReport(report.reportId, report.data) + } + + readonly #handleInputReport = (event: HIDInputReportEvent): void => { + if (event.device !== this.#device || this.#lifecycle !== 'open') + return + + const report = parseDualSenseInputReport(this.#connectionType, event.reportId, event.data) + if (!report) + return + + this.#latestInputReport = report + for (const listener of this.#inputReportListeners) + listener(report) + } +} diff --git a/packages/input-playstation-dualsense-5/src/index.ts b/packages/input-playstation-dualsense-5/src/index.ts new file mode 100644 index 000000000..1c510e7cc --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/index.ts @@ -0,0 +1,37 @@ +export { DualSenseController } from './controller' +export { parseDualSenseInputReport } from './input-report' +export { + buildDualSenseOutputReport, + createDefaultDualSenseOutputState, +} from './output-report' +export type { + DualSenseBatteryState, + DualSenseButtonState, + DualSenseConnectionEvent, + DualSenseConnectionListener, + DualSenseConnectionType, + DualSenseControllerLifecycle, + DualSenseDpadState, + DualSenseInputReport, + DualSenseInputReportListener, + DualSenseInputState, + DualSenseMotionState, + DualSenseOutputReport, + DualSenseOutputState, + DualSenseStickState, + DualSenseTouchPoint, + DualSenseTriggerEffect, + DualSenseTriggerFeedback, + DualSenseTriggerState, + DualSenseVector3, +} from './types' +export { + detectDualSenseConnectionType, + dualSenseProductId, + dualSenseVendorId, + getGrantedDualSenseDevices, + isDualSenseDevice, + isWebHidSupported, + onDualSenseConnectionChange, + requestDualSenseDevice, +} from './web-hid' diff --git a/packages/input-playstation-dualsense-5/src/input-report.test.ts b/packages/input-playstation-dualsense-5/src/input-report.test.ts new file mode 100644 index 000000000..d5a24b040 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/input-report.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import { parseDualSenseInputReport } from './input-report' + +function dataViewFromHex(hex: string): DataView { + const bytes = Uint8Array.from( + hex.trim().split(/\s+/).map(value => Number.parseInt(value, 16)), + ) + return new DataView(bytes.buffer) +} + +describe('parseDualSenseInputReport', () => { + it('parses the published neutral USB report', () => { + const data = dataViewFromHex(` + 7e 81 84 84 00 00 4b 08 00 00 00 ac 0a af 14 f2 + ff 0a 00 f2 ff b8 ff ff 1d 9e 08 da 8f e8 ae 1b + fc 3e 00 26 f9 7f 87 0b bd 09 09 00 00 00 00 00 + 92 a0 e8 ae 29 08 00 b0 7e c8 76 f8 cc a2 2b + `) + + const report = parseDualSenseInputReport('usb', 0x01, data) + + expect(report?.data).toHaveLength(63) + expect(report?.state.sequenceNumber).toBe(0x4B) + expect(report?.state.sticks.left.x).toBeCloseTo(-0.0118, 3) + expect(report?.state.sticks.left.y).toBeCloseTo(0.0118, 3) + expect(report?.state.triggers.left.value).toBe(0) + expect(report?.state.triggers.right.value).toBe(0) + expect(report?.state.dpad).toEqual({ down: false, left: false, right: false, up: false }) + expect(report?.state.buttons.cross).toBe(false) + expect(report?.state.motion).not.toBeNull() + expect(report?.state.battery?.charging).toBe(true) + expect(report?.state.battery?.full).toBe(true) + expect(report?.state.battery?.levelPercent).toBe(100) + }) + + it('parses an extended Bluetooth report', () => { + const bytes = new Uint8Array(77) + const data = new DataView(bytes.buffer) + bytes[1] = 0xFF + bytes[2] = 0x00 + bytes[3] = 0x80 + bytes[4] = 0x7F + bytes[5] = 0x80 + bytes[6] = 0xFF + bytes[8] = 0x21 + bytes[9] = 0x0D + bytes[10] = 0x07 + data.setUint32(12, 0x12345678, true) + data.setInt16(16, -1234, true) + data.setInt16(18, 2345, true) + data.setInt16(20, -3456, true) + data.setInt16(22, 4567, true) + data.setInt16(24, -5678, true) + data.setInt16(26, 6789, true) + bytes[33] = 0x05 + bytes[34] = 0x34 + bytes[35] = 0x12 + bytes[36] = 0x56 + bytes[37] = 0x87 + bytes[42] = 0x13 + bytes[43] = 0x05 + bytes[53] = 0x24 + bytes[54] = 0x08 + + const report = parseDualSenseInputReport('bluetooth', 0x31, data) + + expect(report?.state.sticks.left).toEqual({ x: 1, y: -1 }) + expect(report?.state.dpad).toEqual({ down: false, left: false, right: true, up: true }) + expect(report?.state.buttons.cross).toBe(true) + expect(report?.state.buttons.mute).toBe(true) + expect(report?.state.triggers.left.pressed).toBe(true) + expect(report?.state.triggers.left.value).toBeCloseTo(0.502, 3) + expect(report?.state.triggers.left.feedback).toEqual({ active: false, state: 5 }) + expect(report?.state.triggers.right.feedback).toEqual({ active: true, state: 3 }) + expect(report?.state.timestamp).toBe(0x12345678) + expect(report?.state.motion?.gyroscope).toEqual({ x: -1234, y: 2345, z: -3456 }) + expect(report?.state.motion?.accelerometer).toEqual({ x: 4567, y: -5678, z: 6789 }) + expect(report?.state.touchPoints?.[0]).toEqual({ active: true, id: 5, x: 0x234, y: 0x561 }) + expect(report?.state.touchPoints?.[1]?.active).toBe(false) + expect(report?.state.battery).toEqual({ charging: true, full: true, levelPercent: 50 }) + }) + + it('parses the compact Bluetooth report without unavailable sensors', () => { + const data = dataViewFromHex('7d 7e 83 82 08 00 00 00 00') + + const report = parseDualSenseInputReport('bluetooth', 0x01, data) + + expect(report?.state.motion).toBeNull() + expect(report?.state.touchPoints).toBeNull() + expect(report?.state.battery).toBeNull() + expect(report?.state.buttons.mute).toBe(false) + expect(report?.state.dpad).toEqual({ down: false, left: false, right: false, up: false }) + }) + + it('ignores unsupported report shapes', () => { + expect(parseDualSenseInputReport('unknown', 0x01, new DataView(new ArrayBuffer(63)))).toBeUndefined() + expect(parseDualSenseInputReport('usb', 0x31, new DataView(new ArrayBuffer(77)))).toBeUndefined() + expect(parseDualSenseInputReport('usb', 0x01, new DataView(new ArrayBuffer(62)))).toBeUndefined() + }) +}) diff --git a/packages/input-playstation-dualsense-5/src/input-report.ts b/packages/input-playstation-dualsense-5/src/input-report.ts new file mode 100644 index 000000000..3f7672f73 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/input-report.ts @@ -0,0 +1,244 @@ +import type { + DualSenseButtonState, + DualSenseConnectionType, + DualSenseDpadState, + DualSenseInputReport, + DualSenseInputState, + DualSenseTouchPoint, + DualSenseTriggerFeedback, +} from './types' + +const usbInputReport01Size = 63 +const bluetoothInputReport01Size = 9 +const bluetoothInputReport31Size = 77 + +interface ExtendedInputOffsets { + accelerometer: number + axes: number + battery: number + buttons: number + feedback: number + gyroscope: number + sensorTimestamp: number | null + sequenceNumber: number | null + timestamp: number + touchPoints: number +} + +/** + * Parses one WebHID input report from a standard DualSense controller. + * The function returns `undefined` when the report ID or byte length is not supported. + * + * @example + * const state = parseDualSenseInputReport('bluetooth', 0x01, reportData) + * // => { reportId: 1, state: { sticks: { ... } }, ... } + */ +export function parseDualSenseInputReport( + connectionType: DualSenseConnectionType, + reportId: number, + reportData: DataView, +): DualSenseInputReport | undefined { + let state: DualSenseInputState | undefined + + if (connectionType === 'usb' && reportId === 0x01 && reportData.byteLength === usbInputReport01Size) { + state = parseExtendedInputReport(reportData, { + accelerometer: 21, + axes: 0, + battery: 52, + buttons: 7, + feedback: 41, + gyroscope: 15, + sensorTimestamp: 27, + sequenceNumber: 6, + timestamp: 11, + touchPoints: 32, + }) + } + else if (connectionType === 'bluetooth' && reportId === 0x01 && reportData.byteLength === bluetoothInputReport01Size) { + state = parseCompactBluetoothInputReport(reportData) + } + else if (connectionType === 'bluetooth' && reportId === 0x31 && reportData.byteLength === bluetoothInputReport31Size) { + state = parseExtendedInputReport(reportData, { + accelerometer: 22, + axes: 1, + battery: 53, + buttons: 8, + feedback: 42, + gyroscope: 16, + sensorTimestamp: null, + sequenceNumber: null, + timestamp: 12, + touchPoints: 33, + }) + } + + if (!state) + return undefined + + const data = new Uint8Array(reportData.buffer, reportData.byteOffset, reportData.byteLength).slice() + return { connectionType, data, reportId, state } +} + +function parseCompactBluetoothInputReport(report: DataView): DualSenseInputState { + const buttons0 = report.getUint8(4) + const buttons1 = report.getUint8(5) + const buttons2 = report.getUint8(6) + + return { + battery: null, + buttons: parseButtons(buttons0, buttons1, buttons2, false), + dpad: parseDpad(buttons0), + motion: null, + sensorTimestamp: null, + sequenceNumber: null, + sticks: parseSticks(report, 0), + timestamp: null, + touchPoints: null, + triggers: { + left: { + feedback: null, + pressed: hasBit(buttons1, 2), + value: normalizeTriggerAxis(report.getUint8(7)), + }, + right: { + feedback: null, + pressed: hasBit(buttons1, 3), + value: normalizeTriggerAxis(report.getUint8(8)), + }, + }, + } +} + +function parseExtendedInputReport(report: DataView, offsets: ExtendedInputOffsets): DualSenseInputState { + const buttons0 = report.getUint8(offsets.buttons) + const buttons1 = report.getUint8(offsets.buttons + 1) + const buttons2 = report.getUint8(offsets.buttons + 2) + const rightFeedback = report.getUint8(offsets.feedback) + const leftFeedback = report.getUint8(offsets.feedback + 1) + const battery0 = report.getUint8(offsets.battery) + const battery1 = report.getUint8(offsets.battery + 1) + + return { + battery: { + charging: hasBit(battery1, 3), + full: hasBit(battery0, 5), + levelPercent: Math.min(100, (battery0 & 0x0F) * 100 / 8), + }, + buttons: parseButtons(buttons0, buttons1, buttons2, true), + dpad: parseDpad(buttons0), + motion: { + accelerometer: parseVector3(report, offsets.accelerometer), + gyroscope: parseVector3(report, offsets.gyroscope), + }, + sensorTimestamp: offsets.sensorTimestamp === null + ? null + : report.getUint32(offsets.sensorTimestamp, true), + sequenceNumber: offsets.sequenceNumber === null + ? null + : report.getUint8(offsets.sequenceNumber), + sticks: parseSticks(report, offsets.axes), + timestamp: report.getUint32(offsets.timestamp, true), + touchPoints: [ + parseTouchPoint(report, offsets.touchPoints), + parseTouchPoint(report, offsets.touchPoints + 4), + ], + triggers: { + left: { + feedback: parseTriggerFeedback(leftFeedback), + pressed: hasBit(buttons1, 2), + value: normalizeTriggerAxis(report.getUint8(offsets.axes + 4)), + }, + right: { + feedback: parseTriggerFeedback(rightFeedback), + pressed: hasBit(buttons1, 3), + value: normalizeTriggerAxis(report.getUint8(offsets.axes + 5)), + }, + }, + } +} + +function parseButtons( + buttons0: number, + buttons1: number, + buttons2: number, + hasMuteButton: boolean, +): DualSenseButtonState { + return { + circle: hasBit(buttons0, 6), + create: hasBit(buttons1, 4), + cross: hasBit(buttons0, 5), + l1: hasBit(buttons1, 0), + l3: hasBit(buttons1, 6), + mute: hasMuteButton && hasBit(buttons2, 2), + options: hasBit(buttons1, 5), + ps: hasBit(buttons2, 0), + r1: hasBit(buttons1, 1), + r3: hasBit(buttons1, 7), + square: hasBit(buttons0, 4), + touchpad: hasBit(buttons2, 1), + triangle: hasBit(buttons0, 7), + } +} + +function parseDpad(buttons0: number): DualSenseDpadState { + const direction = buttons0 & 0x0F + return { + down: direction === 3 || direction === 4 || direction === 5, + left: direction === 5 || direction === 6 || direction === 7, + right: direction === 1 || direction === 2 || direction === 3, + up: direction === 0 || direction === 1 || direction === 7, + } +} + +function parseSticks(report: DataView, offset: number): DualSenseInputState['sticks'] { + return { + left: { + x: normalizeThumbStickAxis(report.getUint8(offset)), + y: normalizeThumbStickAxis(report.getUint8(offset + 1)), + }, + right: { + x: normalizeThumbStickAxis(report.getUint8(offset + 2)), + y: normalizeThumbStickAxis(report.getUint8(offset + 3)), + }, + } +} + +function parseVector3(report: DataView, offset: number) { + return { + x: report.getInt16(offset, true), + y: report.getInt16(offset + 2, true), + z: report.getInt16(offset + 4, true), + } +} + +function parseTouchPoint(report: DataView, offset: number): DualSenseTouchPoint { + const byte0 = report.getUint8(offset) + const byte1 = report.getUint8(offset + 1) + const byte2 = report.getUint8(offset + 2) + const byte3 = report.getUint8(offset + 3) + return { + active: (byte0 & 0x80) === 0, + id: byte0 & 0x7F, + x: ((byte2 & 0x0F) << 8) | byte1, + y: (byte3 << 4) | ((byte2 & 0xF0) >> 4), + } +} + +function parseTriggerFeedback(value: number): DualSenseTriggerFeedback { + return { + active: (value & 0x10) !== 0, + state: value & 0x0F, + } +} + +function normalizeThumbStickAxis(value: number): number { + return (2 * value / 0xFF) - 1 +} + +function normalizeTriggerAxis(value: number): number { + return value / 0xFF +} + +function hasBit(value: number, bit: number): boolean { + return (value & (1 << bit)) !== 0 +} diff --git a/packages/input-playstation-dualsense-5/src/output-report.test.ts b/packages/input-playstation-dualsense-5/src/output-report.test.ts new file mode 100644 index 000000000..e47af2733 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/output-report.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { + buildDualSenseOutputReport, + createDefaultDualSenseOutputState, +} from './output-report' + +describe('buildDualSenseOutputReport', () => { + it('builds a USB output report', () => { + const initial = createDefaultDualSenseOutputState() + const report = buildDualSenseOutputReport('usb', { + ...initial, + leftMotor: 300, + lightbar: { blue: 232, green: 178, red: 124 }, + muteLed: true, + playerLeds: [true, false, true, false, true], + rightMotor: -10, + }) + + expect(report.reportId).toBe(0x02) + expect(report.data).toHaveLength(47) + expect(report.data[0]).toBe(0xFF) + expect(report.data[1]).toBe(0xF7) + expect(report.data[2]).toBe(0) + expect(report.data[3]).toBe(0xFF) + expect(report.data[8]).toBe(1) + expect(report.data[9]).toBe(0) + expect(report.data[10]).toBe(0x26) + expect(report.data[11]).toBe(0x90) + expect(report.data[21]).toBe(0x26) + expect(report.data[43]).toBe(0x15) + expect(Array.from(report.data.slice(44, 47))).toEqual([124, 178, 232]) + }) + + it('builds a Bluetooth output report with its sequence and checksum', () => { + const report = buildDualSenseOutputReport( + 'bluetooth', + createDefaultDualSenseOutputState(), + 15, + ) + + expect(report.reportId).toBe(0x31) + expect(report.data).toHaveLength(77) + expect(report.data[0]).toBe(0xF0) + expect(report.data[1]).toBe(0x10) + expect(report.nextSequenceNumber).toBe(0) + expect(Array.from(report.data.slice(-4))).toEqual([0xB0, 0xB6, 0xD2, 0xB2]) + }) + + it('rejects an unknown transport', () => { + expect(() => buildDualSenseOutputReport( + 'unknown', + createDefaultDualSenseOutputState(), + )).toThrowError('The DualSense connection type is unknown.') + }) +}) diff --git a/packages/input-playstation-dualsense-5/src/output-report.ts b/packages/input-playstation-dualsense-5/src/output-report.ts new file mode 100644 index 000000000..d5d7fce18 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/output-report.ts @@ -0,0 +1,116 @@ +import type { + DualSenseConnectionType, + DualSenseOutputReport, + DualSenseOutputState, + DualSenseTriggerEffect, +} from './types' + +/** + * Creates the initial output state used by the DualSense Explorer protocol. + * + * @example + * createDefaultDualSenseOutputState() + * // => { lightbar: { red: 255, green: 255, blue: 255 }, ... } + */ +export function createDefaultDualSenseOutputState(): DualSenseOutputState { + return { + leftMotor: 0, + leftTriggerEffect: createDefaultTriggerEffect(), + lightbar: { blue: 0xFF, green: 0xFF, red: 0xFF }, + muteLed: false, + playerLeds: [false, false, false, false, false], + rightMotor: 0, + rightTriggerEffect: createDefaultTriggerEffect(), + } +} + +/** + * Builds one USB or Bluetooth DualSense output report. + * The sequence number is used only by Bluetooth reports. + */ +export function buildDualSenseOutputReport( + connectionType: DualSenseConnectionType, + output: DualSenseOutputState, + sequenceNumber = 1, +): DualSenseOutputReport { + if (connectionType === 'unknown') + throw new Error('The DualSense connection type is unknown.') + + const reportId = connectionType === 'bluetooth' ? 0x31 : 0x02 + const data = new Uint8Array(connectionType === 'bluetooth' ? 77 : 47) + const commonOffset = connectionType === 'bluetooth' ? 2 : 0 + const common = new DataView(data.buffer, commonOffset, 47) + + if (connectionType === 'bluetooth') { + data[0] = (sequenceNumber & 0x0F) << 4 + data[1] = 0x10 + } + + common.setUint8(0, 0xFF) + common.setUint8(1, 0xF7) + common.setUint8(2, toByte(output.rightMotor)) + common.setUint8(3, toByte(output.leftMotor)) + common.setUint8(8, output.muteLed ? 0x01 : 0x00) + common.setUint8(9, output.muteLed ? 0x00 : 0x10) + writeTriggerEffect(common, 10, output.rightTriggerEffect) + writeTriggerEffect(common, 21, output.leftTriggerEffect) + common.setUint8(39, 0x02) + common.setUint8(41, 0x02) + common.setUint8(43, createPlayerLedMask(output.playerLeds)) + common.setUint8(44, toByte(output.lightbar.red)) + common.setUint8(45, toByte(output.lightbar.green)) + common.setUint8(46, toByte(output.lightbar.blue)) + + if (connectionType === 'bluetooth') + fillBluetoothChecksum(reportId, data) + + return { + data, + nextSequenceNumber: connectionType === 'bluetooth' ? (sequenceNumber + 1) & 0x0F : sequenceNumber, + reportId, + } +} + +function createDefaultTriggerEffect(): DualSenseTriggerEffect { + return { + mode: 0x26, + parameters: [0x90, 0xA0, 0xFF, 0x00, 0x00, 0x00, 0x00], + } +} + +function writeTriggerEffect(view: DataView, offset: number, effect: DualSenseTriggerEffect): void { + view.setUint8(offset, toByte(effect.mode)) + effect.parameters.forEach((parameter, index) => view.setUint8(offset + index + 1, toByte(parameter))) +} + +function createPlayerLedMask(playerLeds: DualSenseOutputState['playerLeds']): number { + return playerLeds.reduce((mask, enabled, index) => enabled ? mask | (1 << index) : mask, 0) +} + +function fillBluetoothChecksum(reportId: number, data: Uint8Array): void { + const checksum = crc32([0xA2, reportId], new DataView(data.buffer, 0, data.byteLength - 4)) + data[data.byteLength - 4] = checksum & 0xFF + data[data.byteLength - 3] = (checksum >>> 8) & 0xFF + data[data.byteLength - 2] = (checksum >>> 16) & 0xFF + data[data.byteLength - 1] = (checksum >>> 24) & 0xFF +} + +function crc32(prefixBytes: readonly number[], data: DataView): number { + let crc = -1 >>> 0 + for (const byte of prefixBytes) + crc = updateCrc32(crc, byte) + for (let index = 0; index < data.byteLength; index++) + crc = updateCrc32(crc, data.getUint8(index)) + return (crc ^ -1) >>> 0 +} + +function updateCrc32(crc: number, byte: number): number { + let value = (crc ^ byte) & 0xFF + for (let index = 0; index < 8; index++) + value = (value & 1) !== 0 ? 0xEDB88320 ^ (value >>> 1) : value >>> 1 + return (crc >>> 8) ^ value +} + +function toByte(value: number): number { + return Math.min(0xFF, Math.max(0, Math.round(value))) +} diff --git a/packages/input-playstation-dualsense-5/src/types.ts b/packages/input-playstation-dualsense-5/src/types.ts new file mode 100644 index 000000000..e8bd34857 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/types.ts @@ -0,0 +1,151 @@ +/** The transport that provides DualSense HID reports. */ +export type DualSenseConnectionType = 'bluetooth' | 'unknown' | 'usb' + +/** The lifecycle state of a {@link DualSenseController}. */ +export type DualSenseControllerLifecycle = 'closed' | 'closing' | 'open' | 'opening' + +/** A normalized two-dimensional stick value. Each axis is in the range from -1 to 1. */ +export interface DualSenseStickState { + readonly x: number + readonly y: number +} + +/** The state of the four directional buttons. */ +export interface DualSenseDpadState { + readonly down: boolean + readonly left: boolean + readonly right: boolean + readonly up: boolean +} + +/** The digital button state that does not belong to a trigger or directional pad. */ +export interface DualSenseButtonState { + readonly circle: boolean + readonly create: boolean + readonly l1: boolean + readonly l3: boolean + readonly mute: boolean + readonly options: boolean + readonly ps: boolean + readonly r1: boolean + readonly r3: boolean + readonly square: boolean + readonly touchpad: boolean + readonly triangle: boolean + readonly cross: boolean +} + +/** Feedback from one adaptive trigger. */ +export interface DualSenseTriggerFeedback { + readonly active: boolean + readonly state: number +} + +/** The input state of one trigger. */ +export interface DualSenseTriggerState { + readonly pressed: boolean + /** The normalized trigger position in the range from 0 to 1. */ + readonly value: number + /** Bluetooth report `0x01` does not contain this value. */ + readonly feedback: DualSenseTriggerFeedback | null +} + +/** One touch point from the DualSense touchpad. */ +export interface DualSenseTouchPoint { + readonly active: boolean + readonly id: number + readonly x: number + readonly y: number +} + +/** A signed three-dimensional sensor value from the controller. */ +export interface DualSenseVector3 { + readonly x: number + readonly y: number + readonly z: number +} + +/** Motion data from an extended USB or Bluetooth report. */ +export interface DualSenseMotionState { + readonly accelerometer: DualSenseVector3 + readonly gyroscope: DualSenseVector3 +} + +/** Battery data from an extended USB or Bluetooth report. */ +export interface DualSenseBatteryState { + readonly charging: boolean + readonly full: boolean + readonly levelPercent: number +} + +/** The parsed state of one DualSense input report. */ +export interface DualSenseInputState { + readonly battery: DualSenseBatteryState | null + readonly buttons: DualSenseButtonState + readonly dpad: DualSenseDpadState + readonly motion: DualSenseMotionState | null + /** This raw controller counter is not a wall-clock timestamp. */ + readonly sensorTimestamp: number | null + readonly sequenceNumber: number | null + readonly sticks: { + readonly left: DualSenseStickState + readonly right: DualSenseStickState + } + /** This raw controller counter is not a wall-clock timestamp. */ + readonly timestamp: number | null + readonly touchPoints: readonly [DualSenseTouchPoint, DualSenseTouchPoint] | null + readonly triggers: { + readonly left: DualSenseTriggerState + readonly right: DualSenseTriggerState + } +} + +/** A parsed input report and its original bytes. */ +export interface DualSenseInputReport { + readonly connectionType: DualSenseConnectionType + /** These bytes do not include the report ID. */ + readonly data: Uint8Array + readonly reportId: number + readonly state: DualSenseInputState +} + +/** The eight-byte effect payload for one adaptive trigger. */ +export interface DualSenseTriggerEffect { + readonly mode: number + readonly parameters: readonly [number, number, number, number, number, number, number] +} + +/** Values for one DualSense output report. Byte values are clamped to the range from 0 to 255. */ +export interface DualSenseOutputState { + readonly leftMotor: number + readonly leftTriggerEffect: DualSenseTriggerEffect + readonly lightbar: { + readonly blue: number + readonly green: number + readonly red: number + } + readonly muteLed: boolean + /** Each item controls one of the five white player LEDs. */ + readonly playerLeds: readonly [boolean, boolean, boolean, boolean, boolean] + readonly rightMotor: number + readonly rightTriggerEffect: DualSenseTriggerEffect +} + +/** Bytes that can be passed to `HIDDevice.sendReport()`. */ +export interface DualSenseOutputReport { + readonly data: Uint8Array + readonly nextSequenceNumber: number + readonly reportId: number +} + +/** A DualSense connect or disconnect event from WebHID. */ +export interface DualSenseConnectionEvent { + readonly device: HIDDevice + readonly type: 'connect' | 'disconnect' +} + +/** Receives parsed input reports from a {@link DualSenseController}. */ +export type DualSenseInputReportListener = (report: DualSenseInputReport) => void + +/** Receives WebHID connection changes for standard DualSense devices. */ +export type DualSenseConnectionListener = (event: DualSenseConnectionEvent) => void diff --git a/packages/input-playstation-dualsense-5/src/web-hid.test.ts b/packages/input-playstation-dualsense-5/src/web-hid.test.ts new file mode 100644 index 000000000..617bf5c28 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/web-hid.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { + detectDualSenseConnectionType, + isDualSenseDevice, +} from './web-hid' + +function createGamepadCollection(reportBits: number): HIDCollectionInfo { + return { + inputReports: [{ + items: [{ reportCount: 1, reportSize: reportBits }], + }], + usage: 0x0005, + usagePage: 0x0001, + } +} + +describe('device detection', () => { + it('matches the standard Sony DualSense identifiers', () => { + expect(isDualSenseDevice({ productId: 0x0CE6, vendorId: 0x054C })).toBe(true) + expect(isDualSenseDevice({ productId: 0x0CE7, vendorId: 0x054C })).toBe(false) + }) + + it('detects USB and Bluetooth report descriptors', () => { + expect(detectDualSenseConnectionType({ collections: [createGamepadCollection(504)] })).toBe('usb') + expect(detectDualSenseConnectionType({ collections: [createGamepadCollection(616)] })).toBe('bluetooth') + expect(detectDualSenseConnectionType({ collections: [createGamepadCollection(512)] })).toBe('unknown') + }) +}) diff --git a/packages/input-playstation-dualsense-5/src/web-hid.ts b/packages/input-playstation-dualsense-5/src/web-hid.ts new file mode 100644 index 000000000..702fe2695 --- /dev/null +++ b/packages/input-playstation-dualsense-5/src/web-hid.ts @@ -0,0 +1,123 @@ +import type { + DualSenseConnectionListener, + DualSenseConnectionType, +} from './types' + +/** Sony's USB vendor ID. */ +export const dualSenseVendorId = 0x054C + +/** The product ID of the standard PlayStation 5 DualSense controller. */ +export const dualSenseProductId = 0x0CE6 + +const genericDesktopUsagePage = 0x0001 +const gamepadUsage = 0x0005 + +/** Returns true when the current browser exposes WebHID. */ +export function isWebHidSupported(): boolean { + return typeof navigator !== 'undefined' && 'hid' in navigator +} + +/** Returns true when a WebHID device is a standard PlayStation 5 DualSense controller. */ +export function isDualSenseDevice(device: Pick): boolean { + return device.vendorId === dualSenseVendorId && device.productId === dualSenseProductId +} + +/** + * Detects the DualSense transport from its generic gamepad collection. + * WebHID does not provide the USB or Bluetooth transport directly. + */ +export function detectDualSenseConnectionType( + device: Pick, +): DualSenseConnectionType { + for (const collection of device.collections) { + if (collection.usagePage !== genericDesktopUsagePage || collection.usage !== gamepadUsage) + continue + + const maximumInputReportBits = getMaximumInputReportBits(collection) + if (maximumInputReportBits === 504) + return 'usb' + if (maximumInputReportBits === 616) + return 'bluetooth' + } + + return 'unknown' +} + +/** + * Opens the WebHID device chooser for a standard DualSense controller. + * Call this function from a user action. + */ +export async function requestDualSenseDevice(hid?: HID): Promise { + const devices = await resolveWebHid(hid).requestDevice({ + filters: [createDualSenseDeviceFilter()], + }) + return devices.find(isDualSenseDevice) +} + +/** Returns the permitted standard DualSense devices that are currently known to WebHID. */ +export async function getGrantedDualSenseDevices(hid?: HID): Promise { + const devices = await resolveWebHid(hid).getDevices() + return devices.filter(isDualSenseDevice) +} + +/** Observes WebHID connect and disconnect events for standard DualSense devices. */ +export function onDualSenseConnectionChange( + listener: DualSenseConnectionListener, + hid?: HID, +): () => void { + const webHid = resolveWebHid(hid) + const handleConnect = (event: HIDConnectionEvent) => { + if (isDualSenseDevice(event.device)) + listener({ device: event.device, type: 'connect' }) + } + const handleDisconnect = (event: HIDConnectionEvent) => { + if (isDualSenseDevice(event.device)) + listener({ device: event.device, type: 'disconnect' }) + } + + webHid.addEventListener('connect', handleConnect) + webHid.addEventListener('disconnect', handleDisconnect) + return () => { + webHid.removeEventListener('connect', handleConnect) + webHid.removeEventListener('disconnect', handleDisconnect) + } +} + +function createDualSenseDeviceFilter(): HIDDeviceFilter { + return { + productId: dualSenseProductId, + usage: gamepadUsage, + usagePage: genericDesktopUsagePage, + vendorId: dualSenseVendorId, + } +} + +function getMaximumInputReportBits(collection: HIDCollectionInfo): number { + const inputReports = collection.inputReports + if (!inputReports) + return 0 + + let maximumBits = 0 + for (const report of inputReports) { + const items = report.items + if (!items) + continue + + let reportBits = 0 + for (const item of items) { + if (item.reportSize === undefined || item.reportCount === undefined) + continue + reportBits += item.reportSize * item.reportCount + } + maximumBits = Math.max(maximumBits, reportBits) + } + return maximumBits +} + +function resolveWebHid(hid: HID | undefined): HID { + if (hid) + return hid + if (!isWebHidSupported()) + throw new Error('WebHID is not available in this browser.') + return navigator.hid +} diff --git a/packages/input-playstation-dualsense-5/tsconfig.json b/packages/input-playstation-dualsense-5/tsconfig.json new file mode 100644 index 000000000..38aba0abd --- /dev/null +++ b/packages/input-playstation-dualsense-5/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "types": [ + "w3c-web-hid" + ] + }, + "include": [ + "src/**/*.ts", + "tsdown.config.ts", + "vitest.config.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/input-playstation-dualsense-5/tsdown.config.ts b/packages/input-playstation-dualsense-5/tsdown.config.ts new file mode 100644 index 000000000..52614d38f --- /dev/null +++ b/packages/input-playstation-dualsense-5/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + platform: 'browser', +}) diff --git a/packages/input-playstation-dualsense-5/vitest.config.ts b/packages/input-playstation-dualsense-5/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/input-playstation-dualsense-5/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a73dedb75..b65f9b90d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,9 @@ catalogs: '@types/vscode': specifier: ^1.134.0 version: 1.134.0 + '@types/w3c-web-hid': + specifier: ^1.0.7 + version: 1.0.7 '@types/whatwg-mimetype': specifier: ^5.0.0 version: 5.0.0 @@ -3742,6 +3745,27 @@ importers: specifier: 'catalog:' version: 2.9.0 + packages/input-gamepad: {} + + packages/input-gamepad-vueuse: + dependencies: + '@proj-airi/input-gamepad': + specifier: workspace:^ + version: link:../input-gamepad + '@vueuse/core': + specifier: 'catalog:' + version: 14.4.0(vue@3.5.41(typescript@6.0.3)) + devDependencies: + vue: + specifier: 'catalog:' + version: 3.5.41(typescript@6.0.3) + + packages/input-playstation-dualsense-5: + dependencies: + '@types/w3c-web-hid': + specifier: 'catalog:' + version: 1.0.7 + packages/memory-pgvector: dependencies: '@guiiai/logg': @@ -11445,6 +11469,9 @@ packages: '@types/vscode@1.134.0': resolution: {integrity: sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==} + '@types/w3c-web-hid@1.0.7': + resolution: {integrity: sha512-/y97wBH7fYB5vKoDIn11O1ZDMNFLCAVqZ9af0OWDN7VSO2ClErEN2HlGbBuPgHBTUmZOVx1og2ZHX1U2gEJM4Q==} + '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} @@ -25427,6 +25454,8 @@ snapshots: '@types/vscode@1.134.0': {} + '@types/w3c-web-hid@1.0.7': {} + '@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.21': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 14b27ea9a..81651be8f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -200,6 +200,7 @@ catalog: '@types/three': ^0.185.4 '@types/unist': ^3.0.3 '@types/vscode': ^1.134.0 + '@types/w3c-web-hid': ^1.0.7 '@types/whatwg-mimetype': ^5.0.0 '@types/ws': ^8.18.1 '@types/xast': ^2.0.4 diff --git a/vitest.config.ts b/vitest.config.ts index 515becfc4..313f9ace8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ 'packages/ccc', 'packages/core-agent', 'packages/i18n', + 'packages/input-gamepad', + 'packages/input-gamepad-vueuse', + 'packages/input-playstation-dualsense-5', 'packages/better-ws', 'packages/plugin-sdk', 'packages/plugin-sdk-tamagotchi',