@realnation/builder-shared-sdk 2.5.0 → 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.
@@ -0,0 +1,140 @@
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 readonly 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
+ /** One-shot look at what is connected right now. */
92
+ private probe;
93
+ private startPolling;
94
+ private onFrame;
95
+ private readControls;
96
+ private flushReleases;
97
+ private emitEvents;
98
+ private emitAxes;
99
+ private setState;
100
+ readonly controls: {
101
+ /** Subscribes to one control. Returns the unsubscribe function. */
102
+ on: (control: ControlName, handler: (event: ControlEvent) => void) => Unsubscribe;
103
+ /** Every control. The runner's key bridge is the intended consumer. */
104
+ onAny: (handler: (event: ControlEvent) => void) => Unsubscribe;
105
+ /** Whether a control is down right now. */
106
+ isPressed: (control: ControlName) => boolean;
107
+ };
108
+ readonly axes: {
109
+ /**
110
+ * Per-frame analog readings, for games that steer proportionally.
111
+ *
112
+ * Do not bind this into a reactive template: it fires on every animation
113
+ * frame. Canvas games should read it and draw (same caution as the
114
+ * runtime SDK's player facade).
115
+ */
116
+ onUpdate: (handler: (update: AxesUpdate) => void) => Unsubscribe;
117
+ current: () => AxesUpdate;
118
+ };
119
+ onStateChange(handler: (state: GamepadState) => void): Unsubscribe;
120
+ /** The device seen last, matched or not. For operator-facing messages. */
121
+ get device(): ProfileLookup | null;
122
+ /**
123
+ * Releases anything held and stops polling, without ending the session.
124
+ *
125
+ * The host calls this when the page is hidden or loses focus: animation
126
+ * frames stop there anyway, so a press held across the boundary would never
127
+ * see its release.
128
+ */
129
+ suspend(): void;
130
+ /** Resumes after `suspend()`. */
131
+ resume(): void;
132
+ /** Ends the session. Host-only: the facade handed to modules hides it. */
133
+ close(): void;
134
+ /** Alias for `close()`, matching the runtime SDK's shape. */
135
+ dispose(): void;
136
+ }
137
+ export declare function createGamepad(options?: CreateGamepadOptions): GamepadSession;
138
+ /** English text for a failed connect, for the permission gate to show. */
139
+ export declare function explainGamepadMiss(status: GamepadStatus): string;
140
+ //# 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,305 @@
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.thresholds = resolveThresholds({ ...profile.thresholds, ...this.thresholdOverrides });
94
+ }
95
+ /** One-shot look at what is connected right now. */
96
+ probe() {
97
+ const found = selectDevice(this.source.read(), this.profiles);
98
+ if (!found)
99
+ return { ok: false, reason: 'NO_DEVICE' };
100
+ this.lastLookup = found.lookup;
101
+ if (!found.lookup.profile) {
102
+ return { ok: false, reason: 'UNKNOWN_DEVICE', deviceId: found.lookup.deviceId };
103
+ }
104
+ this.adoptProfile(found.lookup.profile);
105
+ return {
106
+ ok: true,
107
+ deviceId: found.lookup.deviceId,
108
+ profileId: found.lookup.profile.id,
109
+ profileName: found.lookup.profile.name,
110
+ };
111
+ }
112
+ startPolling() {
113
+ if (this.polling || this.closed)
114
+ return;
115
+ this.polling = true;
116
+ this.setState('waiting');
117
+ this.source.start((pads) => this.onFrame(pads));
118
+ }
119
+ onFrame(pads) {
120
+ if (this.closed)
121
+ return;
122
+ const found = selectDevice(pads, this.profiles);
123
+ const profile = found?.lookup.profile ?? null;
124
+ this.lastLookup = found?.lookup ?? null;
125
+ if (!profile || !found) {
126
+ // The wheel went away mid-press. Release first, then report: a module in
127
+ // hold mode must see the release, and it must arrive before 'lost'.
128
+ if (this.profile)
129
+ this.emitEvents(this.flushReleases());
130
+ this.profile = null;
131
+ this.reading = emptyReading();
132
+ this.setState(this.currentState === 'ready' ? 'lost' : 'waiting');
133
+ return;
134
+ }
135
+ this.adoptProfile(profile);
136
+ this.setState('ready');
137
+ this.reading = this.readControls(found.pad, profile);
138
+ const { state, events } = stepSignals(this.signals, this.reading, this.thresholds);
139
+ this.signals = state;
140
+ this.emitAxes();
141
+ this.emitEvents(events);
142
+ }
143
+ readControls(pad, profile) {
144
+ const axisValue = (control) => {
145
+ const binding = axisBinding(profile, control);
146
+ return binding ? readBinding(binding, pad) : 0;
147
+ };
148
+ const buttons = {};
149
+ // 十字键先解码:它来自一根轴,四个方向是算出来的,不是四个键。
150
+ // 放在按键循环之前,万一某个型号两种都有,显式的按键映射优先。
151
+ if (profile.dpad) {
152
+ const hat = decodeHat(pad.axes[profile.dpad.index] ?? NaN, profile.dpad.released);
153
+ buttons['dpad-up'] = hat.up ? 1 : 0;
154
+ buttons['dpad-right'] = hat.right ? 1 : 0;
155
+ buttons['dpad-down'] = hat.down ? 1 : 0;
156
+ buttons['dpad-left'] = hat.left ? 1 : 0;
157
+ }
158
+ for (const control of BUTTON_CONTROLS) {
159
+ const binding = buttonBinding(profile, control);
160
+ if (binding)
161
+ buttons[control] = readBinding(binding, pad);
162
+ }
163
+ return {
164
+ steering: readBinding(profile.steering, pad),
165
+ throttle: axisValue('throttle'),
166
+ brake: axisValue('brake'),
167
+ clutch: axisValue('clutch'),
168
+ buttons,
169
+ };
170
+ }
171
+ flushReleases() {
172
+ const { state, events } = releaseAll(this.signals);
173
+ this.signals = state;
174
+ return events;
175
+ }
176
+ emitEvents(events) {
177
+ for (const event of events) {
178
+ for (const handler of this.anyHandlers)
179
+ handler(event);
180
+ const handlers = this.controlHandlers.get(event.control);
181
+ if (handlers)
182
+ for (const handler of handlers)
183
+ handler(event);
184
+ }
185
+ }
186
+ emitAxes() {
187
+ if (!this.axesHandlers.size)
188
+ return;
189
+ const update = {
190
+ steering: this.reading.steering,
191
+ throttle: this.reading.throttle,
192
+ brake: this.reading.brake,
193
+ clutch: this.reading.clutch,
194
+ };
195
+ for (const handler of this.axesHandlers)
196
+ handler(update);
197
+ }
198
+ setState(state) {
199
+ if (this.currentState === state)
200
+ return;
201
+ this.currentState = state;
202
+ for (const handler of this.stateHandlers)
203
+ handler(state);
204
+ }
205
+ controls = {
206
+ /** Subscribes to one control. Returns the unsubscribe function. */
207
+ on: (control, handler) => {
208
+ let handlers = this.controlHandlers.get(control);
209
+ if (!handlers) {
210
+ handlers = new Set();
211
+ this.controlHandlers.set(control, handlers);
212
+ }
213
+ handlers.add(handler);
214
+ return () => handlers?.delete(handler);
215
+ },
216
+ /** Every control. The runner's key bridge is the intended consumer. */
217
+ onAny: (handler) => {
218
+ this.anyHandlers.add(handler);
219
+ return () => this.anyHandlers.delete(handler);
220
+ },
221
+ /** Whether a control is down right now. */
222
+ isPressed: (control) => this.signals[control] === true,
223
+ };
224
+ axes = {
225
+ /**
226
+ * Per-frame analog readings, for games that steer proportionally.
227
+ *
228
+ * Do not bind this into a reactive template: it fires on every animation
229
+ * frame. Canvas games should read it and draw (same caution as the
230
+ * runtime SDK's player facade).
231
+ */
232
+ onUpdate: (handler) => {
233
+ this.axesHandlers.add(handler);
234
+ return () => this.axesHandlers.delete(handler);
235
+ },
236
+ current: () => ({
237
+ steering: this.reading.steering,
238
+ throttle: this.reading.throttle,
239
+ brake: this.reading.brake,
240
+ clutch: this.reading.clutch,
241
+ }),
242
+ };
243
+ onStateChange(handler) {
244
+ this.stateHandlers.add(handler);
245
+ return () => this.stateHandlers.delete(handler);
246
+ }
247
+ /** The device seen last, matched or not. For operator-facing messages. */
248
+ get device() {
249
+ return this.lastLookup;
250
+ }
251
+ /**
252
+ * Releases anything held and stops polling, without ending the session.
253
+ *
254
+ * The host calls this when the page is hidden or loses focus: animation
255
+ * frames stop there anyway, so a press held across the boundary would never
256
+ * see its release.
257
+ */
258
+ suspend() {
259
+ if (!this.polling)
260
+ return;
261
+ this.emitEvents(this.flushReleases());
262
+ this.source.stop();
263
+ this.polling = false;
264
+ this.setState('waiting');
265
+ }
266
+ /** Resumes after `suspend()`. */
267
+ resume() {
268
+ if (this.closed || this.polling)
269
+ return;
270
+ this.startPolling();
271
+ }
272
+ /** Ends the session. Host-only: the facade handed to modules hides it. */
273
+ close() {
274
+ if (this.closed)
275
+ return;
276
+ this.emitEvents(this.flushReleases());
277
+ this.source.stop();
278
+ this.polling = false;
279
+ this.closed = true;
280
+ this.setState('idle');
281
+ this.controlHandlers.clear();
282
+ this.anyHandlers.clear();
283
+ this.axesHandlers.clear();
284
+ this.stateHandlers.clear();
285
+ }
286
+ /** Alias for `close()`, matching the runtime SDK's shape. */
287
+ dispose() {
288
+ this.close();
289
+ }
290
+ }
291
+ export function createGamepad(options = {}) {
292
+ return new GamepadSession(options);
293
+ }
294
+ /** English text for a failed connect, for the permission gate to show. */
295
+ export function explainGamepadMiss(status) {
296
+ switch (status.reason) {
297
+ case 'NO_API':
298
+ return 'This browser cannot read game controllers. Use Chrome or Edge on the kiosk.';
299
+ case 'UNKNOWN_DEVICE':
300
+ return `This controller model is not supported yet${status.deviceId ? ` (${status.deviceId})` : ''}. Send this model name to the team to have it added.`;
301
+ case 'NO_DEVICE':
302
+ default:
303
+ return 'No racing wheel was found. Connect the wheel, then press any button on it.';
304
+ }
305
+ }
@@ -0,0 +1,78 @@
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
+ * Starting values, to be tuned against the real wheel before release.
30
+ *
31
+ * Steering sits high so a hand resting slightly off centre does not steer;
32
+ * pedals sit low because timing games score the instant of the press, and a
33
+ * pedal that only registers when floored feels late. A profile may override
34
+ * any of these: a 900-degree wheel needs a much lower steering line than a
35
+ * 270-degree one to feel the same.
36
+ */
37
+ export declare const DEFAULT_THRESHOLDS: Thresholds;
38
+ export declare function resolveThresholds(overrides?: Partial<Thresholds>): Thresholds;
39
+ /** Normalised readings for one frame. Steering is -1..1, everything else 0..1. */
40
+ export interface ControlReading {
41
+ steering: number;
42
+ throttle: number;
43
+ brake: number;
44
+ clutch: number;
45
+ /** Per-role button readings, 0..1. Roles the device lacks are absent. */
46
+ buttons: Partial<Record<ControlName, number>>;
47
+ }
48
+ export interface ControlEvent {
49
+ control: ControlName;
50
+ pressed: boolean;
51
+ }
52
+ /** Which controls are currently down. The whole of the state machine's memory. */
53
+ export type SignalState = Readonly<Partial<Record<ControlName, boolean>>>;
54
+ export declare const EMPTY_SIGNAL_STATE: SignalState;
55
+ export declare function emptyReading(): ControlReading;
56
+ /**
57
+ * Advances the state machine by one frame.
58
+ *
59
+ * Only controls that changed produce events, so a held pedal emits exactly one
60
+ * press however long it is held. That is what keeps the runner's `e.repeat`
61
+ * contract intact for the timing games.
62
+ */
63
+ export declare function stepSignals(previous: SignalState, reading: ControlReading, thresholds?: Thresholds): {
64
+ state: SignalState;
65
+ events: ControlEvent[];
66
+ };
67
+ /**
68
+ * Releases everything currently held.
69
+ *
70
+ * Called whenever polling is about to stop or the device goes away: a page
71
+ * hidden mid-corner stops its animation frames, and a press with no matching
72
+ * release leaves hold-style modules (Video Player) stuck playing forever.
73
+ */
74
+ export declare function releaseAll(previous: SignalState): {
75
+ state: SignalState;
76
+ events: ControlEvent[];
77
+ };
78
+ //# 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;;;;;;;;GAQG;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"}
@@ -0,0 +1,112 @@
1
+ import { AXIS_CONTROLS, BUTTON_CONTROLS } from './controls.js';
2
+ /**
3
+ * Starting values, to be tuned against the real wheel before release.
4
+ *
5
+ * Steering sits high so a hand resting slightly off centre does not steer;
6
+ * pedals sit low because timing games score the instant of the press, and a
7
+ * pedal that only registers when floored feels late. A profile may override
8
+ * any of these: a 900-degree wheel needs a much lower steering line than a
9
+ * 270-degree one to feel the same.
10
+ */
11
+ export const DEFAULT_THRESHOLDS = {
12
+ steeringPress: 0.35,
13
+ steeringRelease: 0.2,
14
+ pedalPress: 0.25,
15
+ pedalRelease: 0.12,
16
+ buttonPress: 0.5,
17
+ buttonRelease: 0.4,
18
+ };
19
+ export function resolveThresholds(overrides) {
20
+ return { ...DEFAULT_THRESHOLDS, ...overrides };
21
+ }
22
+ export const EMPTY_SIGNAL_STATE = {};
23
+ export function emptyReading() {
24
+ return { steering: 0, throttle: 0, brake: 0, clutch: 0, buttons: {} };
25
+ }
26
+ function pedalReading(reading, control) {
27
+ switch (control) {
28
+ case 'throttle':
29
+ return reading.throttle;
30
+ case 'brake':
31
+ return reading.brake;
32
+ case 'clutch':
33
+ return reading.clutch;
34
+ default:
35
+ return 0;
36
+ }
37
+ }
38
+ /**
39
+ * How far one control is engaged, 0..1.
40
+ *
41
+ * The two steering directions read off the same signed axis, each seeing only
42
+ * its own side. That is what makes them mutually exclusive for free: turning
43
+ * left drives `wheel-right` to zero, which releases it.
44
+ */
45
+ function engagement(reading, control) {
46
+ switch (control) {
47
+ case 'wheel-left':
48
+ return Math.max(0, -reading.steering);
49
+ case 'wheel-right':
50
+ return Math.max(0, reading.steering);
51
+ case 'throttle':
52
+ case 'brake':
53
+ case 'clutch':
54
+ return pedalReading(reading, control);
55
+ default:
56
+ return reading.buttons[control] ?? 0;
57
+ }
58
+ }
59
+ function linesFor(control, thresholds) {
60
+ switch (control) {
61
+ case 'wheel-left':
62
+ case 'wheel-right':
63
+ return [thresholds.steeringPress, thresholds.steeringRelease];
64
+ case 'throttle':
65
+ case 'brake':
66
+ case 'clutch':
67
+ return [thresholds.pedalPress, thresholds.pedalRelease];
68
+ default:
69
+ return [thresholds.buttonPress, thresholds.buttonRelease];
70
+ }
71
+ }
72
+ const ALL_CONTROLS = [...AXIS_CONTROLS, ...BUTTON_CONTROLS];
73
+ /**
74
+ * Advances the state machine by one frame.
75
+ *
76
+ * Only controls that changed produce events, so a held pedal emits exactly one
77
+ * press however long it is held. That is what keeps the runner's `e.repeat`
78
+ * contract intact for the timing games.
79
+ */
80
+ export function stepSignals(previous, reading, thresholds = DEFAULT_THRESHOLDS) {
81
+ const next = { ...previous };
82
+ const events = [];
83
+ for (const control of ALL_CONTROLS) {
84
+ const [press, release] = linesFor(control, thresholds);
85
+ const value = engagement(reading, control);
86
+ const wasDown = previous[control] === true;
87
+ if (!wasDown && value >= press) {
88
+ next[control] = true;
89
+ events.push({ control, pressed: true });
90
+ }
91
+ else if (wasDown && value <= release) {
92
+ next[control] = false;
93
+ events.push({ control, pressed: false });
94
+ }
95
+ }
96
+ return { state: next, events };
97
+ }
98
+ /**
99
+ * Releases everything currently held.
100
+ *
101
+ * Called whenever polling is about to stop or the device goes away: a page
102
+ * hidden mid-corner stops its animation frames, and a press with no matching
103
+ * release leaves hold-style modules (Video Player) stuck playing forever.
104
+ */
105
+ export function releaseAll(previous) {
106
+ const events = [];
107
+ for (const control of ALL_CONTROLS) {
108
+ if (previous[control] === true)
109
+ events.push({ control, pressed: false });
110
+ }
111
+ return { state: EMPTY_SIGNAL_STATE, events };
112
+ }