@realnation/builder-shared-sdk 2.5.1 → 2.7.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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The wheel session: one poller, one device, one set of subscribers.
3
+ *
4
+ * The host owns exactly one of these and passes a lifecycle-stripped facade to
5
+ * modules, mirroring how the runner owns the Runtime connection. A federated
6
+ * remote must never create its own: subpath imports are bundled per remote, so
7
+ * every remote that called `createGamepad()` would get its own poller reading
8
+ * the same device, and every device profile would be frozen into whichever SDK
9
+ * version that remote was built against.
10
+ */
11
+ import type { ControlName } from './controls.js';
12
+ import type { GamepadProfile, ProfileLookup } from './profiles.js';
13
+ import type { ControlEvent, Thresholds } from './signals.js';
14
+ import type { GamepadSource } from './source.js';
15
+ export type Unsubscribe = () => void;
16
+ /**
17
+ * `waiting` means the API is there but no supported wheel is: a kiosk whose
18
+ * wheel is unplugged, or a phone. It is a normal resting state, not an error.
19
+ */
20
+ export type GamepadState = 'idle' | 'waiting' | 'ready' | 'lost';
21
+ export type GamepadMissReason = 'NO_API' | 'NO_DEVICE' | 'UNKNOWN_DEVICE';
22
+ export interface GamepadStatus {
23
+ ok: boolean;
24
+ reason?: GamepadMissReason;
25
+ /** The device that was seen, even when it was rejected. */
26
+ deviceId?: string;
27
+ profileId?: string;
28
+ profileName?: string;
29
+ }
30
+ export interface AxesUpdate {
31
+ /** -1 full left, 1 full right. */
32
+ steering: number;
33
+ throttle: number;
34
+ brake: number;
35
+ clutch: number;
36
+ }
37
+ export interface CreateGamepadOptions {
38
+ source?: 'live' | 'mock' | GamepadSource;
39
+ profiles?: readonly GamepadProfile[];
40
+ /** Overrides the profile's thresholds. Mainly for the tuning lab. */
41
+ thresholds?: Partial<Thresholds>;
42
+ /** How long `connect()` waits for a device to appear. */
43
+ waitMs?: number;
44
+ setTimer?: (fn: () => void, ms: number) => unknown;
45
+ clearTimer?: (handle: unknown) => void;
46
+ }
47
+ export declare class GamepadSession {
48
+ private readonly source;
49
+ private readonly profiles;
50
+ private thresholdOverrides?;
51
+ private readonly waitMs;
52
+ private readonly setTimer;
53
+ private readonly clearTimer;
54
+ private readonly controlHandlers;
55
+ private readonly anyHandlers;
56
+ private readonly axesHandlers;
57
+ private readonly stateHandlers;
58
+ private signals;
59
+ private reading;
60
+ private profile;
61
+ private thresholds;
62
+ private currentState;
63
+ private lastLookup;
64
+ private closed;
65
+ private polling;
66
+ constructor(options?: CreateGamepadOptions);
67
+ get state(): GamepadState;
68
+ /** The matched profile, or null while no supported device is connected. */
69
+ get activeProfile(): GamepadProfile | null;
70
+ /**
71
+ * Looks for a supported wheel and starts polling if one is there.
72
+ *
73
+ * Resolving with `ok: false` is not fatal: the session keeps polling and
74
+ * flips to `ready` the moment a supported wheel shows up, which is what lets
75
+ * a player plug one in mid-session. The status is what the permission gate
76
+ * shows the operator.
77
+ *
78
+ * Chrome only exposes gamepads after a user gesture on the page, so calling
79
+ * this straight after load can legitimately report NO_DEVICE; the gate's
80
+ * button press is that gesture.
81
+ */
82
+ connect(): Promise<GamepadStatus>;
83
+ /**
84
+ * Adopts a profile and the thresholds that come with it.
85
+ *
86
+ * Shared by the connect-time probe and the frame loop so that a caller
87
+ * reading `activeProfile` straight after a successful `connect()` sees the
88
+ * device, rather than null until the first animation frame lands.
89
+ */
90
+ private adoptProfile;
91
+ /** Device profile first, caller overrides on top. */
92
+ private recomputeThresholds;
93
+ /** One-shot look at what is connected right now. */
94
+ private probe;
95
+ private startPolling;
96
+ private onFrame;
97
+ private readControls;
98
+ private flushReleases;
99
+ private emitEvents;
100
+ private emitAxes;
101
+ private setState;
102
+ readonly controls: {
103
+ /** Subscribes to one control. Returns the unsubscribe function. */
104
+ on: (control: ControlName, handler: (event: ControlEvent) => void) => Unsubscribe;
105
+ /** Every control. The runner's key bridge is the intended consumer. */
106
+ onAny: (handler: (event: ControlEvent) => void) => Unsubscribe;
107
+ /** Whether a control is down right now. */
108
+ isPressed: (control: ControlName) => boolean;
109
+ };
110
+ readonly axes: {
111
+ /**
112
+ * Per-frame analog readings, for games that steer proportionally.
113
+ *
114
+ * Do not bind this into a reactive template: it fires on every animation
115
+ * frame. Canvas games should read it and draw (same caution as the
116
+ * runtime SDK's player facade).
117
+ */
118
+ onUpdate: (handler: (update: AxesUpdate) => void) => Unsubscribe;
119
+ current: () => AxesUpdate;
120
+ };
121
+ onStateChange(handler: (state: GamepadState) => void): Unsubscribe;
122
+ /** The device seen last, matched or not. For operator-facing messages. */
123
+ get device(): ProfileLookup | null;
124
+ /** The press and release lines in force right now, profile plus overrides. */
125
+ get activeThresholds(): Thresholds;
126
+ /**
127
+ * Changes the press and release lines while the session is running.
128
+ *
129
+ * This is how a campaign tunes the feel: a lane-change game wants the wheel
130
+ * to register early, a racing game wants it steady, and the operator sets
131
+ * that per activity rather than waiting for a release. Overrides sit on top
132
+ * of the device profile, so anything left out keeps the model's own value;
133
+ * pass null to drop the overrides and fall back to the profile.
134
+ *
135
+ * Anything held right now is released first. The presses that are down were
136
+ * decided under the old lines, and carrying them across the change mixes two
137
+ * sets of rules in one state: a control could sit above the new release line
138
+ * forever and never send the release a hold-mode module is waiting for.
139
+ * Starting clean costs one release event and removes the whole problem.
140
+ */
141
+ setThresholds(overrides: Partial<Thresholds> | null): void;
142
+ /**
143
+ * Releases anything held and stops polling, without ending the session.
144
+ *
145
+ * The host calls this when the page is hidden or loses focus: animation
146
+ * frames stop there anyway, so a press held across the boundary would never
147
+ * see its release.
148
+ */
149
+ suspend(): void;
150
+ /** Resumes after `suspend()`. */
151
+ resume(): void;
152
+ /** Ends the session. Host-only: the facade handed to modules hides it. */
153
+ close(): void;
154
+ /** Alias for `close()`, matching the runtime SDK's shape. */
155
+ dispose(): void;
156
+ }
157
+ export declare function createGamepad(options?: CreateGamepadOptions): GamepadSession;
158
+ /** English text for a failed connect, for the permission gate to show. */
159
+ export declare function explainGamepadMiss(status: GamepadStatus): string;
160
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/gamepad/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,336 @@
1
+ import { BUTTON_CONTROLS } from './controls.js';
2
+ import { decodeHat, readBinding } from './normalize.js';
3
+ import { PROFILES, axisBinding, buttonBinding, selectDevice } from './profiles.js';
4
+ import { EMPTY_SIGNAL_STATE, emptyReading, releaseAll, resolveThresholds, stepSignals } from './signals.js';
5
+ import { LiveGamepadSource, MockGamepadSource } from './source.js';
6
+ function buildSource(option) {
7
+ if (!option || option === 'live')
8
+ return new LiveGamepadSource();
9
+ if (option === 'mock')
10
+ return new MockGamepadSource();
11
+ return option;
12
+ }
13
+ export class GamepadSession {
14
+ source;
15
+ profiles;
16
+ thresholdOverrides;
17
+ waitMs;
18
+ setTimer;
19
+ clearTimer;
20
+ controlHandlers = new Map();
21
+ anyHandlers = new Set();
22
+ axesHandlers = new Set();
23
+ stateHandlers = new Set();
24
+ signals = EMPTY_SIGNAL_STATE;
25
+ reading = emptyReading();
26
+ profile = null;
27
+ thresholds = resolveThresholds();
28
+ currentState = 'idle';
29
+ lastLookup = null;
30
+ closed = false;
31
+ polling = false;
32
+ constructor(options = {}) {
33
+ this.source = buildSource(options.source);
34
+ this.profiles = options.profiles ?? PROFILES;
35
+ this.thresholdOverrides = options.thresholds;
36
+ this.waitMs = options.waitMs ?? 0;
37
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
38
+ this.clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
39
+ }
40
+ get state() {
41
+ return this.currentState;
42
+ }
43
+ /** The matched profile, or null while no supported device is connected. */
44
+ get activeProfile() {
45
+ return this.profile;
46
+ }
47
+ /**
48
+ * Looks for a supported wheel and starts polling if one is there.
49
+ *
50
+ * Resolving with `ok: false` is not fatal: the session keeps polling and
51
+ * flips to `ready` the moment a supported wheel shows up, which is what lets
52
+ * a player plug one in mid-session. The status is what the permission gate
53
+ * shows the operator.
54
+ *
55
+ * Chrome only exposes gamepads after a user gesture on the page, so calling
56
+ * this straight after load can legitimately report NO_DEVICE; the gate's
57
+ * button press is that gesture.
58
+ */
59
+ async connect() {
60
+ if (this.closed)
61
+ throw new Error('This gamepad session is closed.');
62
+ if (!this.source.supported)
63
+ return { ok: false, reason: 'NO_API' };
64
+ this.startPolling();
65
+ const immediate = this.probe();
66
+ if (immediate.ok || this.waitMs <= 0)
67
+ return immediate;
68
+ return new Promise((resolve) => {
69
+ const deadline = this.waitMs;
70
+ const started = Date.now();
71
+ const poll = () => {
72
+ if (this.closed)
73
+ return resolve({ ok: false, reason: 'NO_DEVICE' });
74
+ const status = this.probe();
75
+ if (status.ok || Date.now() - started >= deadline)
76
+ return resolve(status);
77
+ this.setTimer(poll, 120);
78
+ };
79
+ this.setTimer(poll, 120);
80
+ });
81
+ }
82
+ /**
83
+ * Adopts a profile and the thresholds that come with it.
84
+ *
85
+ * Shared by the connect-time probe and the frame loop so that a caller
86
+ * reading `activeProfile` straight after a successful `connect()` sees the
87
+ * device, rather than null until the first animation frame lands.
88
+ */
89
+ adoptProfile(profile) {
90
+ if (profile === this.profile)
91
+ return;
92
+ this.profile = profile;
93
+ this.recomputeThresholds();
94
+ }
95
+ /** Device profile first, caller overrides on top. */
96
+ recomputeThresholds() {
97
+ this.thresholds = resolveThresholds({
98
+ ...this.profile?.thresholds,
99
+ ...this.thresholdOverrides,
100
+ });
101
+ }
102
+ /** One-shot look at what is connected right now. */
103
+ probe() {
104
+ const found = selectDevice(this.source.read(), this.profiles);
105
+ if (!found)
106
+ return { ok: false, reason: 'NO_DEVICE' };
107
+ this.lastLookup = found.lookup;
108
+ if (!found.lookup.profile) {
109
+ return { ok: false, reason: 'UNKNOWN_DEVICE', deviceId: found.lookup.deviceId };
110
+ }
111
+ this.adoptProfile(found.lookup.profile);
112
+ return {
113
+ ok: true,
114
+ deviceId: found.lookup.deviceId,
115
+ profileId: found.lookup.profile.id,
116
+ profileName: found.lookup.profile.name,
117
+ };
118
+ }
119
+ startPolling() {
120
+ if (this.polling || this.closed)
121
+ return;
122
+ this.polling = true;
123
+ this.setState('waiting');
124
+ this.source.start((pads) => this.onFrame(pads));
125
+ }
126
+ onFrame(pads) {
127
+ if (this.closed)
128
+ return;
129
+ const found = selectDevice(pads, this.profiles);
130
+ const profile = found?.lookup.profile ?? null;
131
+ this.lastLookup = found?.lookup ?? null;
132
+ if (!profile || !found) {
133
+ // The wheel went away mid-press. Release first, then report: a module in
134
+ // hold mode must see the release, and it must arrive before 'lost'.
135
+ if (this.profile)
136
+ this.emitEvents(this.flushReleases());
137
+ this.profile = null;
138
+ this.reading = emptyReading();
139
+ this.setState(this.currentState === 'ready' ? 'lost' : 'waiting');
140
+ return;
141
+ }
142
+ this.adoptProfile(profile);
143
+ this.setState('ready');
144
+ this.reading = this.readControls(found.pad, profile);
145
+ const { state, events } = stepSignals(this.signals, this.reading, this.thresholds);
146
+ this.signals = state;
147
+ this.emitAxes();
148
+ this.emitEvents(events);
149
+ }
150
+ readControls(pad, profile) {
151
+ const axisValue = (control) => {
152
+ const binding = axisBinding(profile, control);
153
+ return binding ? readBinding(binding, pad) : 0;
154
+ };
155
+ const buttons = {};
156
+ // 十字键先解码:它来自一根轴,四个方向是算出来的,不是四个键。
157
+ // 放在按键循环之前,万一某个型号两种都有,显式的按键映射优先。
158
+ if (profile.dpad) {
159
+ const hat = decodeHat(pad.axes[profile.dpad.index] ?? NaN, profile.dpad.released);
160
+ buttons['dpad-up'] = hat.up ? 1 : 0;
161
+ buttons['dpad-right'] = hat.right ? 1 : 0;
162
+ buttons['dpad-down'] = hat.down ? 1 : 0;
163
+ buttons['dpad-left'] = hat.left ? 1 : 0;
164
+ }
165
+ for (const control of BUTTON_CONTROLS) {
166
+ const binding = buttonBinding(profile, control);
167
+ if (binding)
168
+ buttons[control] = readBinding(binding, pad);
169
+ }
170
+ return {
171
+ steering: readBinding(profile.steering, pad),
172
+ throttle: axisValue('throttle'),
173
+ brake: axisValue('brake'),
174
+ clutch: axisValue('clutch'),
175
+ buttons,
176
+ };
177
+ }
178
+ flushReleases() {
179
+ const { state, events } = releaseAll(this.signals);
180
+ this.signals = state;
181
+ return events;
182
+ }
183
+ emitEvents(events) {
184
+ for (const event of events) {
185
+ for (const handler of this.anyHandlers)
186
+ handler(event);
187
+ const handlers = this.controlHandlers.get(event.control);
188
+ if (handlers)
189
+ for (const handler of handlers)
190
+ handler(event);
191
+ }
192
+ }
193
+ emitAxes() {
194
+ if (!this.axesHandlers.size)
195
+ return;
196
+ const update = {
197
+ steering: this.reading.steering,
198
+ throttle: this.reading.throttle,
199
+ brake: this.reading.brake,
200
+ clutch: this.reading.clutch,
201
+ };
202
+ for (const handler of this.axesHandlers)
203
+ handler(update);
204
+ }
205
+ setState(state) {
206
+ if (this.currentState === state)
207
+ return;
208
+ this.currentState = state;
209
+ for (const handler of this.stateHandlers)
210
+ handler(state);
211
+ }
212
+ controls = {
213
+ /** Subscribes to one control. Returns the unsubscribe function. */
214
+ on: (control, handler) => {
215
+ let handlers = this.controlHandlers.get(control);
216
+ if (!handlers) {
217
+ handlers = new Set();
218
+ this.controlHandlers.set(control, handlers);
219
+ }
220
+ handlers.add(handler);
221
+ return () => handlers?.delete(handler);
222
+ },
223
+ /** Every control. The runner's key bridge is the intended consumer. */
224
+ onAny: (handler) => {
225
+ this.anyHandlers.add(handler);
226
+ return () => this.anyHandlers.delete(handler);
227
+ },
228
+ /** Whether a control is down right now. */
229
+ isPressed: (control) => this.signals[control] === true,
230
+ };
231
+ axes = {
232
+ /**
233
+ * Per-frame analog readings, for games that steer proportionally.
234
+ *
235
+ * Do not bind this into a reactive template: it fires on every animation
236
+ * frame. Canvas games should read it and draw (same caution as the
237
+ * runtime SDK's player facade).
238
+ */
239
+ onUpdate: (handler) => {
240
+ this.axesHandlers.add(handler);
241
+ return () => this.axesHandlers.delete(handler);
242
+ },
243
+ current: () => ({
244
+ steering: this.reading.steering,
245
+ throttle: this.reading.throttle,
246
+ brake: this.reading.brake,
247
+ clutch: this.reading.clutch,
248
+ }),
249
+ };
250
+ onStateChange(handler) {
251
+ this.stateHandlers.add(handler);
252
+ return () => this.stateHandlers.delete(handler);
253
+ }
254
+ /** The device seen last, matched or not. For operator-facing messages. */
255
+ get device() {
256
+ return this.lastLookup;
257
+ }
258
+ /** The press and release lines in force right now, profile plus overrides. */
259
+ get activeThresholds() {
260
+ return { ...this.thresholds };
261
+ }
262
+ /**
263
+ * Changes the press and release lines while the session is running.
264
+ *
265
+ * This is how a campaign tunes the feel: a lane-change game wants the wheel
266
+ * to register early, a racing game wants it steady, and the operator sets
267
+ * that per activity rather than waiting for a release. Overrides sit on top
268
+ * of the device profile, so anything left out keeps the model's own value;
269
+ * pass null to drop the overrides and fall back to the profile.
270
+ *
271
+ * Anything held right now is released first. The presses that are down were
272
+ * decided under the old lines, and carrying them across the change mixes two
273
+ * sets of rules in one state: a control could sit above the new release line
274
+ * forever and never send the release a hold-mode module is waiting for.
275
+ * Starting clean costs one release event and removes the whole problem.
276
+ */
277
+ setThresholds(overrides) {
278
+ this.thresholdOverrides = overrides ?? undefined;
279
+ this.recomputeThresholds();
280
+ this.emitEvents(this.flushReleases());
281
+ }
282
+ /**
283
+ * Releases anything held and stops polling, without ending the session.
284
+ *
285
+ * The host calls this when the page is hidden or loses focus: animation
286
+ * frames stop there anyway, so a press held across the boundary would never
287
+ * see its release.
288
+ */
289
+ suspend() {
290
+ if (!this.polling)
291
+ return;
292
+ this.emitEvents(this.flushReleases());
293
+ this.source.stop();
294
+ this.polling = false;
295
+ this.setState('waiting');
296
+ }
297
+ /** Resumes after `suspend()`. */
298
+ resume() {
299
+ if (this.closed || this.polling)
300
+ return;
301
+ this.startPolling();
302
+ }
303
+ /** Ends the session. Host-only: the facade handed to modules hides it. */
304
+ close() {
305
+ if (this.closed)
306
+ return;
307
+ this.emitEvents(this.flushReleases());
308
+ this.source.stop();
309
+ this.polling = false;
310
+ this.closed = true;
311
+ this.setState('idle');
312
+ this.controlHandlers.clear();
313
+ this.anyHandlers.clear();
314
+ this.axesHandlers.clear();
315
+ this.stateHandlers.clear();
316
+ }
317
+ /** Alias for `close()`, matching the runtime SDK's shape. */
318
+ dispose() {
319
+ this.close();
320
+ }
321
+ }
322
+ export function createGamepad(options = {}) {
323
+ return new GamepadSession(options);
324
+ }
325
+ /** English text for a failed connect, for the permission gate to show. */
326
+ export function explainGamepadMiss(status) {
327
+ switch (status.reason) {
328
+ case 'NO_API':
329
+ return 'This browser cannot read game controllers. Use Chrome or Edge on the kiosk.';
330
+ case 'UNKNOWN_DEVICE':
331
+ return `This controller model is not supported yet${status.deviceId ? ` (${status.deviceId})` : ''}. Send this model name to the team to have it added.`;
332
+ case 'NO_DEVICE':
333
+ default:
334
+ return 'No racing wheel was found. Connect the wheel, then press any button on it.';
335
+ }
336
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Turning continuous readings into press and release.
3
+ *
4
+ * A wheel reports how far it is turned; the platform's existing physical-input
5
+ * contract only knows "pressed" and "released". This is where the two meet.
6
+ *
7
+ * Two lines, not one. A single threshold would chatter: a wheel resting near
8
+ * it, or a foot resting on a pedal, would fire press and release many times a
9
+ * second. Crossing the press line fires once; nothing more happens until the
10
+ * reading falls back inside the release line (PF 2026-09-20: one press per
11
+ * turn, no auto-repeat, matching Lane Runner's one-press-one-lane ruling).
12
+ *
13
+ * Pure: a snapshot in, the next snapshot and the events to emit out. The
14
+ * session owns the polling; this file can be reasoned about and tested on its
15
+ * own.
16
+ */
17
+ import type { ControlName } from './controls.js';
18
+ export interface Thresholds {
19
+ /** Fraction of full lock at which steering counts as turned. */
20
+ steeringPress: number;
21
+ steeringRelease: number;
22
+ /** Fraction of pedal travel at which a pedal counts as pressed. */
23
+ pedalPress: number;
24
+ pedalRelease: number;
25
+ buttonPress: number;
26
+ buttonRelease: number;
27
+ }
28
+ /**
29
+ * Steering was tuned on the real wheel (PF 2026-09-22): at 0.35 of full lock
30
+ * it had to be dragged round too far before a lane change registered, 0.2
31
+ * lands where a player expects it. Release sits at half of press, so the wheel
32
+ * has to come most of the way back before the next turn counts.
33
+ *
34
+ * Pedals sit where they are because timing games score the instant of the
35
+ * press, and a pedal that only registers when floored feels late. That they
36
+ * now read higher than steering is not a contradiction: a fifth of full lock
37
+ * is a real turn of the wheel, a quarter of pedal travel is a light touch.
38
+ *
39
+ * A profile may override any of these: a 900-degree wheel needs a much lower
40
+ * steering line than a 270-degree one to feel the same.
41
+ */
42
+ export declare const DEFAULT_THRESHOLDS: Thresholds;
43
+ export declare function resolveThresholds(overrides?: Partial<Thresholds>): Thresholds;
44
+ /** Normalised readings for one frame. Steering is -1..1, everything else 0..1. */
45
+ export interface ControlReading {
46
+ steering: number;
47
+ throttle: number;
48
+ brake: number;
49
+ clutch: number;
50
+ /** Per-role button readings, 0..1. Roles the device lacks are absent. */
51
+ buttons: Partial<Record<ControlName, number>>;
52
+ }
53
+ export interface ControlEvent {
54
+ control: ControlName;
55
+ pressed: boolean;
56
+ }
57
+ /** Which controls are currently down. The whole of the state machine's memory. */
58
+ export type SignalState = Readonly<Partial<Record<ControlName, boolean>>>;
59
+ export declare const EMPTY_SIGNAL_STATE: SignalState;
60
+ export declare function emptyReading(): ControlReading;
61
+ /**
62
+ * Advances the state machine by one frame.
63
+ *
64
+ * Only controls that changed produce events, so a held pedal emits exactly one
65
+ * press however long it is held. That is what keeps the runner's `e.repeat`
66
+ * contract intact for the timing games.
67
+ */
68
+ export declare function stepSignals(previous: SignalState, reading: ControlReading, thresholds?: Thresholds): {
69
+ state: SignalState;
70
+ events: ControlEvent[];
71
+ };
72
+ /**
73
+ * Releases everything currently held.
74
+ *
75
+ * Called whenever polling is about to stop or the device goes away: a page
76
+ * hidden mid-corner stops its animation frames, and a press with no matching
77
+ * release leaves hold-style modules (Video Player) stuck playing forever.
78
+ */
79
+ export declare function releaseAll(previous: SignalState): {
80
+ state: SignalState;
81
+ events: ControlEvent[];
82
+ };
83
+ //# sourceMappingURL=signals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signals.d.ts","sourceRoot":"","sources":["../../src/gamepad/signals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAe,WAAW,EAAE,MAAM,eAAe,CAAC;AAG9D,MAAM,WAAW,UAAU;IACzB,gEAAgE;IAChE,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,mEAAmE;IACnE,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,kBAAkB,EAAE,UAOhC,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,UAAU,CAE7E;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;CAC/C;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,WAAW,CAAC;IACrB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,kFAAkF;AAClF,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;AAE1E,eAAO,MAAM,kBAAkB,EAAE,WAAgB,CAAC;AAElD,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAqDD;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,QAAQ,EAAE,WAAW,EACrB,OAAO,EAAE,cAAc,EACvB,UAAU,GAAE,UAA+B,GAC1C;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,YAAY,EAAE,CAAA;CAAE,CAmBhD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,WAAW,GAAG;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,YAAY,EAAE,CAAA;CAAE,CAMhG"}