@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.
- 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 +160 -0
- package/dist/gamepad/session.d.ts.map +1 -0
- package/dist/gamepad/session.js +336 -0
- package/dist/gamepad/signals.d.ts +83 -0
- package/dist/gamepad/signals.d.ts.map +1 -0
- package/dist/gamepad/signals.js +117 -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,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"}
|