@realnation/builder-shared-sdk 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -252
- package/dist/gamepad/controls.d.ts +44 -0
- package/dist/gamepad/controls.d.ts.map +1 -0
- package/dist/gamepad/controls.js +109 -0
- package/dist/gamepad/index.d.ts +26 -0
- package/dist/gamepad/index.d.ts.map +1 -0
- package/dist/gamepad/index.js +19 -0
- package/dist/gamepad/normalize.d.ts +121 -0
- package/dist/gamepad/normalize.d.ts.map +1 -0
- package/dist/gamepad/normalize.js +110 -0
- package/dist/gamepad/profiles.d.ts +81 -0
- package/dist/gamepad/profiles.d.ts.map +1 -0
- package/dist/gamepad/profiles.js +99 -0
- package/dist/gamepad/session.d.ts +140 -0
- package/dist/gamepad/session.d.ts.map +1 -0
- package/dist/gamepad/session.js +305 -0
- package/dist/gamepad/signals.d.ts +78 -0
- package/dist/gamepad/signals.d.ts.map +1 -0
- package/dist/gamepad/signals.js +112 -0
- package/dist/gamepad/source.d.ts +78 -0
- package/dist/gamepad/source.d.ts.map +1 -0
- package/dist/gamepad/source.js +92 -0
- package/package.json +68 -64
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform's vocabulary of wheel controls.
|
|
3
|
+
*
|
|
4
|
+
* These names describe a *role* on a racing wheel, never the glyph printed on
|
|
5
|
+
* one particular device. A second wheel model maps its own axes and buttons
|
|
6
|
+
* onto the same names, so the Builder's Key Event Code dropdown never has to
|
|
7
|
+
* change when hardware does.
|
|
8
|
+
*
|
|
9
|
+
* Each control owns a code in the 1001+ range, deliberately outside the
|
|
10
|
+
* keyboard keyCode space. The runner synthesises keyboard events carrying
|
|
11
|
+
* these codes so that every element which already binds a Key Event Code works
|
|
12
|
+
* with a wheel without any module change. The stored value is the number: the
|
|
13
|
+
* English label is display only and can be reworded later.
|
|
14
|
+
*/
|
|
15
|
+
/** Continuous controls. The wheel reports a direction as two opposite controls. */
|
|
16
|
+
export const AXIS_CONTROLS = ['wheel-left', 'wheel-right', 'throttle', 'brake', 'clutch'];
|
|
17
|
+
/**
|
|
18
|
+
* Button roles. Named after the standard gamepad layout so a second model can
|
|
19
|
+
* be mapped by position rather than by the glyph it happens to print.
|
|
20
|
+
* `aux-1..4` absorbs whatever a device has that does not fit the roles above.
|
|
21
|
+
*/
|
|
22
|
+
export const BUTTON_CONTROLS = [
|
|
23
|
+
'start',
|
|
24
|
+
'select',
|
|
25
|
+
'home',
|
|
26
|
+
'paddle-left',
|
|
27
|
+
'paddle-right',
|
|
28
|
+
'dpad-up',
|
|
29
|
+
'dpad-down',
|
|
30
|
+
'dpad-left',
|
|
31
|
+
'dpad-right',
|
|
32
|
+
'action-1',
|
|
33
|
+
'action-2',
|
|
34
|
+
'action-3',
|
|
35
|
+
'action-4',
|
|
36
|
+
'shoulder-left',
|
|
37
|
+
'shoulder-right',
|
|
38
|
+
'aux-1',
|
|
39
|
+
'aux-2',
|
|
40
|
+
'aux-3',
|
|
41
|
+
'aux-4',
|
|
42
|
+
];
|
|
43
|
+
export const CONTROL_NAMES = [...AXIS_CONTROLS, ...BUTTON_CONTROLS];
|
|
44
|
+
/**
|
|
45
|
+
* Axis controls take 1001-1010, buttons 1011 onwards, with gaps left so a later
|
|
46
|
+
* addition in one group never renumbers the other. Codes are persisted in
|
|
47
|
+
* campaign data: once shipped, a code may never be reassigned to a different
|
|
48
|
+
* control.
|
|
49
|
+
*/
|
|
50
|
+
const AXIS_BASE = 1001;
|
|
51
|
+
const BUTTON_BASE = 1011;
|
|
52
|
+
const LABELS = {
|
|
53
|
+
'wheel-left': 'Wheel Left',
|
|
54
|
+
'wheel-right': 'Wheel Right',
|
|
55
|
+
throttle: 'Throttle',
|
|
56
|
+
brake: 'Brake',
|
|
57
|
+
clutch: 'Clutch',
|
|
58
|
+
start: 'Wheel Start',
|
|
59
|
+
select: 'Wheel Select',
|
|
60
|
+
home: 'Wheel Home',
|
|
61
|
+
'paddle-left': 'Paddle Left',
|
|
62
|
+
'paddle-right': 'Paddle Right',
|
|
63
|
+
'dpad-up': 'D-pad Up',
|
|
64
|
+
'dpad-down': 'D-pad Down',
|
|
65
|
+
'dpad-left': 'D-pad Left',
|
|
66
|
+
'dpad-right': 'D-pad Right',
|
|
67
|
+
'action-1': 'Action 1',
|
|
68
|
+
'action-2': 'Action 2',
|
|
69
|
+
'action-3': 'Action 3',
|
|
70
|
+
'action-4': 'Action 4',
|
|
71
|
+
'shoulder-left': 'Shoulder Left',
|
|
72
|
+
'shoulder-right': 'Shoulder Right',
|
|
73
|
+
'aux-1': 'Aux 1',
|
|
74
|
+
'aux-2': 'Aux 2',
|
|
75
|
+
'aux-3': 'Aux 3',
|
|
76
|
+
'aux-4': 'Aux 4',
|
|
77
|
+
};
|
|
78
|
+
export const CONTROLS = [
|
|
79
|
+
...AXIS_CONTROLS.map((name, index) => ({ name, code: AXIS_BASE + index, label: LABELS[name] })),
|
|
80
|
+
...BUTTON_CONTROLS.map((name, index) => ({
|
|
81
|
+
name,
|
|
82
|
+
code: BUTTON_BASE + index,
|
|
83
|
+
label: LABELS[name],
|
|
84
|
+
})),
|
|
85
|
+
];
|
|
86
|
+
const BY_NAME = new Map(CONTROLS.map((c) => [c.name, c]));
|
|
87
|
+
const BY_CODE = new Map(CONTROLS.map((c) => [c.code, c]));
|
|
88
|
+
export function controlByName(name) {
|
|
89
|
+
const found = BY_NAME.get(name);
|
|
90
|
+
if (!found)
|
|
91
|
+
throw new Error(`Unknown control: ${name}`);
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
export function controlByCode(code) {
|
|
95
|
+
return BY_CODE.get(code) ?? null;
|
|
96
|
+
}
|
|
97
|
+
export function controlCode(name) {
|
|
98
|
+
return controlByName(name).code;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* True for codes this module owns. The runner uses it to keep synthetic wheel
|
|
102
|
+
* events apart from real keyboard ones.
|
|
103
|
+
*/
|
|
104
|
+
export function isControlCode(code) {
|
|
105
|
+
return BY_CODE.has(code);
|
|
106
|
+
}
|
|
107
|
+
export function isAxisControl(name) {
|
|
108
|
+
return AXIS_CONTROLS.includes(name);
|
|
109
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QikSense Gamepad SDK — racing wheels and pedals for web experiences.
|
|
3
|
+
*
|
|
4
|
+
* A module subscribes to named controls (`wheel-left`, `throttle`, `start`)
|
|
5
|
+
* and never touches `navigator.getGamepads()`. Device layout, normalisation
|
|
6
|
+
* and the press/release decision all live below this line, so a second wheel
|
|
7
|
+
* model is a profile entry rather than a change in every game.
|
|
8
|
+
*
|
|
9
|
+
* Framework-free by construction, like `/runtime`: no Vue import anywhere in
|
|
10
|
+
* this directory, so a Canvas game or a node test pulls in no reactivity.
|
|
11
|
+
*
|
|
12
|
+
* The host creates the session; modules receive it. See README.md.
|
|
13
|
+
*/
|
|
14
|
+
export { createGamepad, GamepadSession, explainGamepadMiss } from './session.js';
|
|
15
|
+
export type { AxesUpdate, CreateGamepadOptions, GamepadMissReason, GamepadState, GamepadStatus, Unsubscribe, } from './session.js';
|
|
16
|
+
export { AXIS_CONTROLS, BUTTON_CONTROLS, CONTROLS, CONTROL_NAMES, controlByCode, controlByName, controlCode, isAxisControl, isControlCode, } from './controls.js';
|
|
17
|
+
export type { AxisControl, ButtonControl, ControlDefinition, ControlName } from './controls.js';
|
|
18
|
+
export { DEFAULT_THRESHOLDS, releaseAll, resolveThresholds, stepSignals } from './signals.js';
|
|
19
|
+
export type { ControlEvent, ControlReading, SignalState, Thresholds } from './signals.js';
|
|
20
|
+
export { PROFILES, matchProfile, selectDevice } from './profiles.js';
|
|
21
|
+
export type { GamepadProfile, ProfileLookup, ProfileMatch } from './profiles.js';
|
|
22
|
+
export { decodeHat, deviceKey, normalizeBinding, parseDeviceIdentity, readBinding } from './normalize.js';
|
|
23
|
+
export type { AxisBinding, Binding, ButtonBinding, DeviceIdentity, GamepadLike, HatBinding, } from './normalize.js';
|
|
24
|
+
export { LiveGamepadSource, MockGamepadSource, mockPad } from './source.js';
|
|
25
|
+
export type { GamepadSource, MockGamepadOptions } from './source.js';
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/gamepad/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACjF,YAAY,EACV,UAAU,EACV,oBAAoB,EACpB,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,WAAW,GACZ,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,aAAa,EACb,eAAe,EACf,QAAQ,EACR,aAAa,EACb,aAAa,EACb,aAAa,EACb,WAAW,EACX,aAAa,EACb,aAAa,GACd,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEhG,OAAO,EAAE,kBAAkB,EAAE,UAAU,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC9F,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AACrE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAEjF,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC1G,YAAY,EACV,WAAW,EACX,OAAO,EACP,aAAa,EACb,cAAc,EACd,WAAW,EACX,UAAU,GACX,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC5E,YAAY,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QikSense Gamepad SDK — racing wheels and pedals for web experiences.
|
|
3
|
+
*
|
|
4
|
+
* A module subscribes to named controls (`wheel-left`, `throttle`, `start`)
|
|
5
|
+
* and never touches `navigator.getGamepads()`. Device layout, normalisation
|
|
6
|
+
* and the press/release decision all live below this line, so a second wheel
|
|
7
|
+
* model is a profile entry rather than a change in every game.
|
|
8
|
+
*
|
|
9
|
+
* Framework-free by construction, like `/runtime`: no Vue import anywhere in
|
|
10
|
+
* this directory, so a Canvas game or a node test pulls in no reactivity.
|
|
11
|
+
*
|
|
12
|
+
* The host creates the session; modules receive it. See README.md.
|
|
13
|
+
*/
|
|
14
|
+
export { createGamepad, GamepadSession, explainGamepadMiss } from './session.js';
|
|
15
|
+
export { AXIS_CONTROLS, BUTTON_CONTROLS, CONTROLS, CONTROL_NAMES, controlByCode, controlByName, controlCode, isAxisControl, isControlCode, } from './controls.js';
|
|
16
|
+
export { DEFAULT_THRESHOLDS, releaseAll, resolveThresholds, stepSignals } from './signals.js';
|
|
17
|
+
export { PROFILES, matchProfile, selectDevice } from './profiles.js';
|
|
18
|
+
export { decodeHat, deviceKey, normalizeBinding, parseDeviceIdentity, readBinding } from './normalize.js';
|
|
19
|
+
export { LiveGamepadSource, MockGamepadSource, mockPad } from './source.js';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning what a device reports into numbers a game can use.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the racing-game prototype's `src/input/gamepad.ts`, which had
|
|
5
|
+
* already been proven against a real wheel and carried its own unit tests. The
|
|
6
|
+
* substance is unchanged; what changed is where the binding comes from. The
|
|
7
|
+
* prototype asked every player to calibrate on the spot, so its bindings were
|
|
8
|
+
* discovered at runtime. Here they come from a device profile shipped with the
|
|
9
|
+
* SDK, because a kiosk operator should never have to calibrate anything.
|
|
10
|
+
*
|
|
11
|
+
* Pure functions over plain data: no browser API, no state, no framework.
|
|
12
|
+
*/
|
|
13
|
+
/** Where one control's reading comes from on the device. */
|
|
14
|
+
export type BindingSource = 'axis' | 'button';
|
|
15
|
+
export interface AxisBinding {
|
|
16
|
+
source: 'axis';
|
|
17
|
+
/** Index into `Gamepad.axes`. */
|
|
18
|
+
index: number;
|
|
19
|
+
/** The reading when nothing is touched. Not always 0: pedals often rest at -1. */
|
|
20
|
+
rest: number;
|
|
21
|
+
/** The reading at full deflection in the direction this binding measures. */
|
|
22
|
+
active: number;
|
|
23
|
+
/**
|
|
24
|
+
* Present only for a two-sided axis such as steering, where one axis carries
|
|
25
|
+
* both directions. Lets an asymmetric wheel normalise correctly on each side.
|
|
26
|
+
*/
|
|
27
|
+
minimum?: number;
|
|
28
|
+
maximum?: number;
|
|
29
|
+
/** Readings within this much of `rest` count as untouched. */
|
|
30
|
+
deadzone: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ButtonBinding {
|
|
33
|
+
source: 'button';
|
|
34
|
+
/** Index into `Gamepad.buttons`. */
|
|
35
|
+
index: number;
|
|
36
|
+
rest: number;
|
|
37
|
+
active: number;
|
|
38
|
+
deadzone: number;
|
|
39
|
+
}
|
|
40
|
+
export type Binding = AxisBinding | ButtonBinding;
|
|
41
|
+
/**
|
|
42
|
+
* A D-pad reported as one axis instead of four buttons.
|
|
43
|
+
*
|
|
44
|
+
* Browsers hand a HID hat switch to the page as a single axis carrying eight
|
|
45
|
+
* directions plus a released state, so it cannot be expressed as a button
|
|
46
|
+
* index. Confirmed on the PS3/PC Wheel on 2026-09-21: axis 9 sits at 3.2857
|
|
47
|
+
* untouched and sweeps down to -1.
|
|
48
|
+
*/
|
|
49
|
+
export interface HatBinding {
|
|
50
|
+
source: 'hat';
|
|
51
|
+
/** Index into `Gamepad.axes`. */
|
|
52
|
+
index: number;
|
|
53
|
+
/**
|
|
54
|
+
* The reading when no direction is held. Usually out of the -1..1 range
|
|
55
|
+
* (3.2857 is the common one). Left unset, any out-of-range value counts as
|
|
56
|
+
* released, which covers every device seen so far.
|
|
57
|
+
*/
|
|
58
|
+
released?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Decodes a hat axis into the four directions.
|
|
62
|
+
*
|
|
63
|
+
* Diagonals set both neighbours, which is what a game wants: holding up-right
|
|
64
|
+
* on a menu should move up and right, not neither. The released state is the
|
|
65
|
+
* value falling outside -1..1, so a profile only needs to name it when a
|
|
66
|
+
* device is unusual.
|
|
67
|
+
*/
|
|
68
|
+
export declare function decodeHat(value: number, released?: number): {
|
|
69
|
+
up: boolean;
|
|
70
|
+
right: boolean;
|
|
71
|
+
down: boolean;
|
|
72
|
+
left: boolean;
|
|
73
|
+
};
|
|
74
|
+
/** The subset of `Gamepad` this module reads. Keeps tests free of DOM types. */
|
|
75
|
+
export interface GamepadLike {
|
|
76
|
+
id: string;
|
|
77
|
+
index: number;
|
|
78
|
+
connected: boolean;
|
|
79
|
+
mapping?: string;
|
|
80
|
+
axes: readonly number[];
|
|
81
|
+
buttons: readonly {
|
|
82
|
+
pressed: boolean;
|
|
83
|
+
value: number;
|
|
84
|
+
}[];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Maps a raw reading onto 0..1, or -1..1 for a two-sided axis.
|
|
88
|
+
*
|
|
89
|
+
* A two-sided axis is normalised against each side's own travel, so a wheel
|
|
90
|
+
* whose centre does not sit exactly halfway still reaches 1 and -1 at full
|
|
91
|
+
* lock. Every other binding is one-sided and returns 0..1.
|
|
92
|
+
*/
|
|
93
|
+
export declare function normalizeBinding(binding: Binding, raw: number): number;
|
|
94
|
+
/** Reads one binding off a device snapshot. Absent indices read as untouched. */
|
|
95
|
+
export declare function readBinding(binding: Binding, pad: GamepadLike): number;
|
|
96
|
+
/**
|
|
97
|
+
* A device fingerprint: the id plus its shape.
|
|
98
|
+
*
|
|
99
|
+
* The counts matter because vendor and product ids are not unique in practice.
|
|
100
|
+
* Generic controller boards get resold under many names, so two different
|
|
101
|
+
* wheels can report the same pair of numbers while exposing different axes.
|
|
102
|
+
*/
|
|
103
|
+
export declare function deviceKey(pad: GamepadLike): string;
|
|
104
|
+
export interface DeviceIdentity {
|
|
105
|
+
/** Vendor id as lowercase hex, e.g. `2563`. Null when the id carries none. */
|
|
106
|
+
vendor: string | null;
|
|
107
|
+
product: string | null;
|
|
108
|
+
/** The id with the vendor/product part stripped, for display. */
|
|
109
|
+
name: string;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Pulls vendor and product out of a gamepad id string.
|
|
113
|
+
*
|
|
114
|
+
* Browsers disagree on the format. Chrome writes
|
|
115
|
+
* `PS3/PC Wheel (Vendor: 2563 Product: 0906)`, Firefox writes
|
|
116
|
+
* `2563-0906-PS3/PC Wheel`. Both are recognised so one profile covers both,
|
|
117
|
+
* and an unparseable id degrades to a name with no numbers rather than
|
|
118
|
+
* throwing: matching then simply fails and the operator sees the raw id.
|
|
119
|
+
*/
|
|
120
|
+
export declare function parseDeviceIdentity(id: string): DeviceIdentity;
|
|
121
|
+
//# sourceMappingURL=normalize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../src/gamepad/normalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,4DAA4D;AAC5D,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE9C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,QAAQ,CAAC;IACjB,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,OAAO,GAAG,WAAW,GAAG,aAAa,CAAC;AAElD;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,KAAK,CAAC;IACd,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAKD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,KAAK,EAAE,MAAM,EACb,QAAQ,CAAC,EAAE,MAAM,GAChB;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAc/D;AAED,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,OAAO,EAAE,SAAS;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACzD;AAOD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAiBtE;AAED,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,WAAW,GAAG,MAAM,CAMtE;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,CAElD;AAED,MAAM,WAAW,cAAc;IAC7B,8EAA8E;IAC9E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,MAAM,GAAG,cAAc,CAoB9D"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning what a device reports into numbers a game can use.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the racing-game prototype's `src/input/gamepad.ts`, which had
|
|
5
|
+
* already been proven against a real wheel and carried its own unit tests. The
|
|
6
|
+
* substance is unchanged; what changed is where the binding comes from. The
|
|
7
|
+
* prototype asked every player to calibrate on the spot, so its bindings were
|
|
8
|
+
* discovered at runtime. Here they come from a device profile shipped with the
|
|
9
|
+
* SDK, because a kiosk operator should never have to calibrate anything.
|
|
10
|
+
*
|
|
11
|
+
* Pure functions over plain data: no browser API, no state, no framework.
|
|
12
|
+
*/
|
|
13
|
+
/** The eight directions are -1 plus this step, so one step is an eighth turn. */
|
|
14
|
+
const HAT_STEP = 2 / 7;
|
|
15
|
+
/**
|
|
16
|
+
* Decodes a hat axis into the four directions.
|
|
17
|
+
*
|
|
18
|
+
* Diagonals set both neighbours, which is what a game wants: holding up-right
|
|
19
|
+
* on a menu should move up and right, not neither. The released state is the
|
|
20
|
+
* value falling outside -1..1, so a profile only needs to name it when a
|
|
21
|
+
* device is unusual.
|
|
22
|
+
*/
|
|
23
|
+
export function decodeHat(value, released) {
|
|
24
|
+
const none = { up: false, right: false, down: false, left: false };
|
|
25
|
+
if (!Number.isFinite(value))
|
|
26
|
+
return none;
|
|
27
|
+
if (released !== undefined && Math.abs(value - released) < 0.05)
|
|
28
|
+
return none;
|
|
29
|
+
if (Math.abs(value) > 1.05)
|
|
30
|
+
return none;
|
|
31
|
+
const step = Math.round((value + 1) / HAT_STEP);
|
|
32
|
+
if (step < 0 || step > 7)
|
|
33
|
+
return none;
|
|
34
|
+
return {
|
|
35
|
+
up: step === 7 || step === 0 || step === 1,
|
|
36
|
+
right: step >= 1 && step <= 3,
|
|
37
|
+
down: step >= 3 && step <= 5,
|
|
38
|
+
left: step >= 5 && step <= 7,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
|
42
|
+
/** Guards the divisions below: a zero span would otherwise produce Infinity. */
|
|
43
|
+
const nonZero = (value) => (Math.abs(value) < 0.0001 ? 1 : value);
|
|
44
|
+
/**
|
|
45
|
+
* Maps a raw reading onto 0..1, or -1..1 for a two-sided axis.
|
|
46
|
+
*
|
|
47
|
+
* A two-sided axis is normalised against each side's own travel, so a wheel
|
|
48
|
+
* whose centre does not sit exactly halfway still reaches 1 and -1 at full
|
|
49
|
+
* lock. Every other binding is one-sided and returns 0..1.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeBinding(binding, raw) {
|
|
52
|
+
if (binding.source === 'button') {
|
|
53
|
+
return clamp01((raw - binding.rest) / nonZero(binding.active - binding.rest));
|
|
54
|
+
}
|
|
55
|
+
if (binding.minimum !== undefined && binding.maximum !== undefined) {
|
|
56
|
+
const delta = raw - binding.rest;
|
|
57
|
+
if (Math.abs(delta) <= binding.deadzone)
|
|
58
|
+
return 0;
|
|
59
|
+
return delta < 0
|
|
60
|
+
? -clamp01(Math.abs(delta) / Math.max(binding.deadzone, Math.abs(binding.minimum - binding.rest)))
|
|
61
|
+
: clamp01(delta / Math.max(binding.deadzone, binding.maximum - binding.rest));
|
|
62
|
+
}
|
|
63
|
+
const amount = (raw - binding.rest) / nonZero(binding.active - binding.rest);
|
|
64
|
+
return clamp01(Math.abs(raw - binding.rest) <= binding.deadzone ? 0 : amount);
|
|
65
|
+
}
|
|
66
|
+
/** Reads one binding off a device snapshot. Absent indices read as untouched. */
|
|
67
|
+
export function readBinding(binding, pad) {
|
|
68
|
+
const raw = binding.source === 'axis'
|
|
69
|
+
? (pad.axes[binding.index] ?? binding.rest)
|
|
70
|
+
: (pad.buttons[binding.index]?.value ?? binding.rest);
|
|
71
|
+
return normalizeBinding(binding, raw);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A device fingerprint: the id plus its shape.
|
|
75
|
+
*
|
|
76
|
+
* The counts matter because vendor and product ids are not unique in practice.
|
|
77
|
+
* Generic controller boards get resold under many names, so two different
|
|
78
|
+
* wheels can report the same pair of numbers while exposing different axes.
|
|
79
|
+
*/
|
|
80
|
+
export function deviceKey(pad) {
|
|
81
|
+
return `${pad.id}|a${pad.axes.length}|b${pad.buttons.length}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Pulls vendor and product out of a gamepad id string.
|
|
85
|
+
*
|
|
86
|
+
* Browsers disagree on the format. Chrome writes
|
|
87
|
+
* `PS3/PC Wheel (Vendor: 2563 Product: 0906)`, Firefox writes
|
|
88
|
+
* `2563-0906-PS3/PC Wheel`. Both are recognised so one profile covers both,
|
|
89
|
+
* and an unparseable id degrades to a name with no numbers rather than
|
|
90
|
+
* throwing: matching then simply fails and the operator sees the raw id.
|
|
91
|
+
*/
|
|
92
|
+
export function parseDeviceIdentity(id) {
|
|
93
|
+
const chrome = id.match(/vendor:\s*([0-9a-f]{4})\s+product:\s*([0-9a-f]{4})/i);
|
|
94
|
+
if (chrome) {
|
|
95
|
+
return {
|
|
96
|
+
vendor: chrome[1].toLowerCase(),
|
|
97
|
+
product: chrome[2].toLowerCase(),
|
|
98
|
+
name: id.replace(/\s*\((?:vendor|Vendor).*?\)\s*/, '').trim() || id,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const firefox = id.match(/^([0-9a-f]{4})-([0-9a-f]{4})-(.*)$/i);
|
|
102
|
+
if (firefox) {
|
|
103
|
+
return {
|
|
104
|
+
vendor: firefox[1].toLowerCase(),
|
|
105
|
+
product: firefox[2].toLowerCase(),
|
|
106
|
+
name: firefox[3].trim(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { vendor: null, product: null, name: id };
|
|
110
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device profiles: what each wheel model's axes and buttons mean.
|
|
3
|
+
*
|
|
4
|
+
* A profile replaces on-site calibration. The operator plugs a wheel in and
|
|
5
|
+
* plays; nobody walks through a wizard, and nothing is stored in the browser
|
|
6
|
+
* that a cleared cache could lose. The cost is that a new model needs a
|
|
7
|
+
* profile added here and the SDK republished, which is the trade the team
|
|
8
|
+
* accepted: wheel models change rarely, kiosks are set up often.
|
|
9
|
+
*
|
|
10
|
+
* Recording a new model: open `examples/gamepad/reader.html`, run through the
|
|
11
|
+
* steps with the device, and paste the JSON it produces into a new entry.
|
|
12
|
+
*/
|
|
13
|
+
import type { AxisBinding, Binding, ButtonBinding, GamepadLike, HatBinding } from './normalize.js';
|
|
14
|
+
import type { AxisControl, ButtonControl } from './controls.js';
|
|
15
|
+
import type { Thresholds } from './signals.js';
|
|
16
|
+
export interface ProfileMatch {
|
|
17
|
+
vendor: string;
|
|
18
|
+
product: string;
|
|
19
|
+
/**
|
|
20
|
+
* Required shape. Vendor and product ids are not reliably unique, so a
|
|
21
|
+
* profile only claims a device whose axis and button counts also line up.
|
|
22
|
+
* A mismatch is reported as an unknown device rather than silently driving
|
|
23
|
+
* the wrong axes.
|
|
24
|
+
*/
|
|
25
|
+
axes: number;
|
|
26
|
+
buttons: number;
|
|
27
|
+
}
|
|
28
|
+
export interface GamepadProfile {
|
|
29
|
+
/** Stable identifier used in logs and support conversations. */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Human readable model name. */
|
|
32
|
+
name: string;
|
|
33
|
+
match: ProfileMatch;
|
|
34
|
+
/** Steering is one two-sided axis: negative is left, positive is right. */
|
|
35
|
+
steering: AxisBinding;
|
|
36
|
+
throttle?: Binding;
|
|
37
|
+
brake?: Binding;
|
|
38
|
+
clutch?: Binding;
|
|
39
|
+
/** Button role to device button index. Roles a device lacks are left out. */
|
|
40
|
+
buttons: Partial<Record<ButtonControl, number>>;
|
|
41
|
+
/** Set when the D-pad arrives as a hat axis rather than as four buttons. */
|
|
42
|
+
dpad?: HatBinding;
|
|
43
|
+
/** Per-model overrides for the press/release lines. Merged over defaults. */
|
|
44
|
+
thresholds?: Partial<Thresholds>;
|
|
45
|
+
/** Set when the model is known to expose a working vibration actuator. */
|
|
46
|
+
vibration?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Registry of known wheels.
|
|
50
|
+
*
|
|
51
|
+
* Entries land here only after a real device has been recorded with
|
|
52
|
+
* `examples/gamepad/reader.html`. A guessed profile is worse than none: an
|
|
53
|
+
* unknown device tells the operator plainly that the model is unsupported,
|
|
54
|
+
* whereas a wrong profile drives the wrong axes and looks like a broken game.
|
|
55
|
+
*/
|
|
56
|
+
export declare const PROFILES: readonly GamepadProfile[];
|
|
57
|
+
export interface ProfileLookup {
|
|
58
|
+
profile: GamepadProfile | null;
|
|
59
|
+
/** The device id as reported, so an unmatched device can be named in the UI. */
|
|
60
|
+
deviceId: string;
|
|
61
|
+
vendor: string | null;
|
|
62
|
+
product: string | null;
|
|
63
|
+
}
|
|
64
|
+
/** Finds the profile for one device snapshot. */
|
|
65
|
+
export declare function matchProfile(pad: GamepadLike, profiles?: readonly GamepadProfile[]): ProfileLookup;
|
|
66
|
+
/**
|
|
67
|
+
* Picks the device to drive from everything currently connected.
|
|
68
|
+
*
|
|
69
|
+
* A kiosk has one wheel, so the first device with a profile wins. When none
|
|
70
|
+
* matches, the first connected device is still returned so the operator can be
|
|
71
|
+
* told which model was seen and rejected.
|
|
72
|
+
*/
|
|
73
|
+
export declare function selectDevice(pads: readonly (GamepadLike | null)[], profiles?: readonly GamepadProfile[]): {
|
|
74
|
+
pad: GamepadLike;
|
|
75
|
+
lookup: ProfileLookup;
|
|
76
|
+
} | null;
|
|
77
|
+
/** The binding for one axis control, or null when the model lacks that pedal. */
|
|
78
|
+
export declare function axisBinding(profile: GamepadProfile, control: AxisControl): Binding | null;
|
|
79
|
+
/** Synthesises a binding for a mapped button so it reads like any other control. */
|
|
80
|
+
export declare function buttonBinding(profile: GamepadProfile, control: ButtonControl): ButtonBinding | null;
|
|
81
|
+
//# sourceMappingURL=profiles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/gamepad/profiles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEnG,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,gEAAgE;IAChE,EAAE,EAAE,MAAM,CAAC;IACX,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,YAAY,CAAC;IACpB,2EAA2E;IAC3E,QAAQ,EAAE,WAAW,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,6EAA6E;IAC7E,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,4EAA4E;IAC5E,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IACjC,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AA0CD;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,EAAE,SAAS,cAAc,EAAmB,CAAC;AAElE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IAC/B,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,iDAAiD;AACjD,wBAAgB,YAAY,CAC1B,GAAG,EAAE,WAAW,EAChB,QAAQ,GAAE,SAAS,cAAc,EAAa,GAC7C,aAAa,CAYf;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EAAE,EACrC,QAAQ,GAAE,SAAS,cAAc,EAAa,GAC7C;IAAE,GAAG,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,aAAa,CAAA;CAAE,GAAG,IAAI,CASpD;AAED,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,GAAG,IAAI,CAczF;AAED,oFAAoF;AACpF,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,aAAa,GAAG,aAAa,GAAG,IAAI,CAInG"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { parseDeviceIdentity } from './normalize.js';
|
|
2
|
+
/**
|
|
3
|
+
* The first wheel the platform supports, recorded 2026-09-21 and 09-22.
|
|
4
|
+
*
|
|
5
|
+
* Two things about it are worth knowing because they are not obvious and both
|
|
6
|
+
* cost a round of debugging:
|
|
7
|
+
*
|
|
8
|
+
* ① **The two pedals share one axis.** Accelerator pushes axis 1 negative,
|
|
9
|
+
* brake pushes it positive, and untouched it rests near zero. That means
|
|
10
|
+
* pressing both at once cancels out; the hardware cannot report them
|
|
11
|
+
* separately and no amount of code can recover it.
|
|
12
|
+
* ② **The D-pad is a hat axis**, not four buttons (see `HatBinding`).
|
|
13
|
+
*
|
|
14
|
+
* Buttons 10 and 11 are declared by the device but have no physical control
|
|
15
|
+
* behind them, so they are deliberately unmapped.
|
|
16
|
+
*/
|
|
17
|
+
const PS3_PC_WHEEL = {
|
|
18
|
+
id: 'ps3-pc-wheel-2563-0906',
|
|
19
|
+
name: 'PS3/PC Racing Wheel',
|
|
20
|
+
match: { vendor: '2563', product: '0906', axes: 10, buttons: 12 },
|
|
21
|
+
// 静止 0.004 而不是 0:转向轴的中位本来就偏一点,死区要盖得住
|
|
22
|
+
steering: { source: 'axis', index: 0, rest: 0.004, active: 1, minimum: -1, maximum: 1, deadzone: 0.06 },
|
|
23
|
+
throttle: { source: 'axis', index: 1, rest: 0.004, active: -1, deadzone: 0.06 },
|
|
24
|
+
brake: { source: 'axis', index: 1, rest: 0.004, active: 1, deadzone: 0.06 },
|
|
25
|
+
dpad: { source: 'hat', index: 9, released: 3.2857 },
|
|
26
|
+
buttons: {
|
|
27
|
+
// 四个符号键按位置对应,不按符号本身:换一只贴 ABXY 的方向盘也映射到同一套名字
|
|
28
|
+
'action-1': 2, // X,下
|
|
29
|
+
'action-2': 1, // 圆圈,右
|
|
30
|
+
'action-3': 3, // 方形,左
|
|
31
|
+
'action-4': 0, // 三角,上
|
|
32
|
+
'shoulder-left': 4, // L1
|
|
33
|
+
'shoulder-right': 5, // R1
|
|
34
|
+
// 换挡拨片与 L2 / R2 是同一对物理键(实测两次都落在同一个下标)
|
|
35
|
+
'paddle-left': 6, // L2 = Gear Down
|
|
36
|
+
'paddle-right': 7, // R2 = Gear Up
|
|
37
|
+
select: 8,
|
|
38
|
+
start: 9,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Registry of known wheels.
|
|
43
|
+
*
|
|
44
|
+
* Entries land here only after a real device has been recorded with
|
|
45
|
+
* `examples/gamepad/reader.html`. A guessed profile is worse than none: an
|
|
46
|
+
* unknown device tells the operator plainly that the model is unsupported,
|
|
47
|
+
* whereas a wrong profile drives the wrong axes and looks like a broken game.
|
|
48
|
+
*/
|
|
49
|
+
export const PROFILES = [PS3_PC_WHEEL];
|
|
50
|
+
/** Finds the profile for one device snapshot. */
|
|
51
|
+
export function matchProfile(pad, profiles = PROFILES) {
|
|
52
|
+
const identity = parseDeviceIdentity(pad.id);
|
|
53
|
+
const profile = profiles.find((candidate) => candidate.match.vendor.toLowerCase() === identity.vendor &&
|
|
54
|
+
candidate.match.product.toLowerCase() === identity.product &&
|
|
55
|
+
candidate.match.axes === pad.axes.length &&
|
|
56
|
+
candidate.match.buttons === pad.buttons.length) ?? null;
|
|
57
|
+
return { profile, deviceId: pad.id, vendor: identity.vendor, product: identity.product };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Picks the device to drive from everything currently connected.
|
|
61
|
+
*
|
|
62
|
+
* A kiosk has one wheel, so the first device with a profile wins. When none
|
|
63
|
+
* matches, the first connected device is still returned so the operator can be
|
|
64
|
+
* told which model was seen and rejected.
|
|
65
|
+
*/
|
|
66
|
+
export function selectDevice(pads, profiles = PROFILES) {
|
|
67
|
+
const connected = pads.filter((pad) => Boolean(pad?.connected));
|
|
68
|
+
if (!connected.length)
|
|
69
|
+
return null;
|
|
70
|
+
for (const pad of connected) {
|
|
71
|
+
const lookup = matchProfile(pad, profiles);
|
|
72
|
+
if (lookup.profile)
|
|
73
|
+
return { pad, lookup };
|
|
74
|
+
}
|
|
75
|
+
return { pad: connected[0], lookup: matchProfile(connected[0], profiles) };
|
|
76
|
+
}
|
|
77
|
+
/** The binding for one axis control, or null when the model lacks that pedal. */
|
|
78
|
+
export function axisBinding(profile, control) {
|
|
79
|
+
switch (control) {
|
|
80
|
+
case 'wheel-left':
|
|
81
|
+
case 'wheel-right':
|
|
82
|
+
return profile.steering;
|
|
83
|
+
case 'throttle':
|
|
84
|
+
return profile.throttle ?? null;
|
|
85
|
+
case 'brake':
|
|
86
|
+
return profile.brake ?? null;
|
|
87
|
+
case 'clutch':
|
|
88
|
+
return profile.clutch ?? null;
|
|
89
|
+
default:
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Synthesises a binding for a mapped button so it reads like any other control. */
|
|
94
|
+
export function buttonBinding(profile, control) {
|
|
95
|
+
const index = profile.buttons[control];
|
|
96
|
+
if (index === undefined)
|
|
97
|
+
return null;
|
|
98
|
+
return { source: 'button', index, rest: 0, active: 1, deadzone: 0.04 };
|
|
99
|
+
}
|