@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,117 @@
1
+ import { AXIS_CONTROLS, BUTTON_CONTROLS } from './controls.js';
2
+ /**
3
+ * Steering was tuned on the real wheel (PF 2026-09-22): at 0.35 of full lock
4
+ * it had to be dragged round too far before a lane change registered, 0.2
5
+ * lands where a player expects it. Release sits at half of press, so the wheel
6
+ * has to come most of the way back before the next turn counts.
7
+ *
8
+ * Pedals sit where they are because timing games score the instant of the
9
+ * press, and a pedal that only registers when floored feels late. That they
10
+ * now read higher than steering is not a contradiction: a fifth of full lock
11
+ * is a real turn of the wheel, a quarter of pedal travel is a light touch.
12
+ *
13
+ * A profile may override any of these: a 900-degree wheel needs a much lower
14
+ * steering line than a 270-degree one to feel the same.
15
+ */
16
+ export const DEFAULT_THRESHOLDS = {
17
+ steeringPress: 0.2,
18
+ steeringRelease: 0.1,
19
+ pedalPress: 0.25,
20
+ pedalRelease: 0.12,
21
+ buttonPress: 0.5,
22
+ buttonRelease: 0.4,
23
+ };
24
+ export function resolveThresholds(overrides) {
25
+ return { ...DEFAULT_THRESHOLDS, ...overrides };
26
+ }
27
+ export const EMPTY_SIGNAL_STATE = {};
28
+ export function emptyReading() {
29
+ return { steering: 0, throttle: 0, brake: 0, clutch: 0, buttons: {} };
30
+ }
31
+ function pedalReading(reading, control) {
32
+ switch (control) {
33
+ case 'throttle':
34
+ return reading.throttle;
35
+ case 'brake':
36
+ return reading.brake;
37
+ case 'clutch':
38
+ return reading.clutch;
39
+ default:
40
+ return 0;
41
+ }
42
+ }
43
+ /**
44
+ * How far one control is engaged, 0..1.
45
+ *
46
+ * The two steering directions read off the same signed axis, each seeing only
47
+ * its own side. That is what makes them mutually exclusive for free: turning
48
+ * left drives `wheel-right` to zero, which releases it.
49
+ */
50
+ function engagement(reading, control) {
51
+ switch (control) {
52
+ case 'wheel-left':
53
+ return Math.max(0, -reading.steering);
54
+ case 'wheel-right':
55
+ return Math.max(0, reading.steering);
56
+ case 'throttle':
57
+ case 'brake':
58
+ case 'clutch':
59
+ return pedalReading(reading, control);
60
+ default:
61
+ return reading.buttons[control] ?? 0;
62
+ }
63
+ }
64
+ function linesFor(control, thresholds) {
65
+ switch (control) {
66
+ case 'wheel-left':
67
+ case 'wheel-right':
68
+ return [thresholds.steeringPress, thresholds.steeringRelease];
69
+ case 'throttle':
70
+ case 'brake':
71
+ case 'clutch':
72
+ return [thresholds.pedalPress, thresholds.pedalRelease];
73
+ default:
74
+ return [thresholds.buttonPress, thresholds.buttonRelease];
75
+ }
76
+ }
77
+ const ALL_CONTROLS = [...AXIS_CONTROLS, ...BUTTON_CONTROLS];
78
+ /**
79
+ * Advances the state machine by one frame.
80
+ *
81
+ * Only controls that changed produce events, so a held pedal emits exactly one
82
+ * press however long it is held. That is what keeps the runner's `e.repeat`
83
+ * contract intact for the timing games.
84
+ */
85
+ export function stepSignals(previous, reading, thresholds = DEFAULT_THRESHOLDS) {
86
+ const next = { ...previous };
87
+ const events = [];
88
+ for (const control of ALL_CONTROLS) {
89
+ const [press, release] = linesFor(control, thresholds);
90
+ const value = engagement(reading, control);
91
+ const wasDown = previous[control] === true;
92
+ if (!wasDown && value >= press) {
93
+ next[control] = true;
94
+ events.push({ control, pressed: true });
95
+ }
96
+ else if (wasDown && value <= release) {
97
+ next[control] = false;
98
+ events.push({ control, pressed: false });
99
+ }
100
+ }
101
+ return { state: next, events };
102
+ }
103
+ /**
104
+ * Releases everything currently held.
105
+ *
106
+ * Called whenever polling is about to stop or the device goes away: a page
107
+ * hidden mid-corner stops its animation frames, and a press with no matching
108
+ * release leaves hold-style modules (Video Player) stuck playing forever.
109
+ */
110
+ export function releaseAll(previous) {
111
+ const events = [];
112
+ for (const control of ALL_CONTROLS) {
113
+ if (previous[control] === true)
114
+ events.push({ control, pressed: false });
115
+ }
116
+ return { state: EMPTY_SIGNAL_STATE, events };
117
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Where device snapshots come from.
3
+ *
4
+ * The session never touches `navigator` directly: it polls a source. The live
5
+ * source reads the Gamepad API on animation frames; the mock source replays a
6
+ * script. Tests and machines without a wheel drive the same session code the
7
+ * hardware does, which is the point (same principle as the runtime mock: it
8
+ * may be stricter than the real thing, never looser).
9
+ */
10
+ import type { GamepadLike } from './normalize.js';
11
+ export interface GamepadSource {
12
+ /** Starts delivering snapshots. Called once when the session connects. */
13
+ start(onFrame: (pads: readonly GamepadLike[]) => void): void;
14
+ /** Stops delivering. Must be safe to call when never started. */
15
+ stop(): void;
16
+ /** One snapshot on demand, for the connect-time device probe. */
17
+ read(): readonly GamepadLike[];
18
+ /** True when this environment can report gamepads at all. */
19
+ readonly supported: boolean;
20
+ }
21
+ /**
22
+ * Reads the browser's Gamepad API.
23
+ *
24
+ * Polling is the only option the API offers: there are no per-frame events,
25
+ * and Chrome hands out a fresh snapshot object on every call. `getGamepads()`
26
+ * returns a sparse array whose holes are disconnected slots, hence the filter.
27
+ */
28
+ export declare class LiveGamepadSource implements GamepadSource {
29
+ private readonly requestFrame;
30
+ private readonly cancelFrame;
31
+ private handle;
32
+ private onFrame;
33
+ readonly supported: boolean;
34
+ constructor(requestFrame?: (cb: () => void) => number, cancelFrame?: (handle: number) => void);
35
+ read(): readonly GamepadLike[];
36
+ start(onFrame: (pads: readonly GamepadLike[]) => void): void;
37
+ stop(): void;
38
+ }
39
+ export interface MockGamepadOptions {
40
+ /** The device to report. Absent means "nothing plugged in". */
41
+ pad?: GamepadLike | null;
42
+ /** Leaves frame delivery to `step()` instead of a timer. For tests. */
43
+ manual?: boolean;
44
+ /** Frame interval when not manual. */
45
+ intervalMs?: number;
46
+ setTimer?: (fn: () => void, ms: number) => unknown;
47
+ clearTimer?: (handle: unknown) => void;
48
+ }
49
+ /**
50
+ * A scripted device.
51
+ *
52
+ * Mutate `pad` between frames to drive a turn or a pedal. In manual mode the
53
+ * caller decides when a frame happens, so a test can assert on exactly the
54
+ * sequence it wrote rather than on whatever a timer produced.
55
+ */
56
+ export declare class MockGamepadSource implements GamepadSource {
57
+ readonly supported = true;
58
+ private onFrame;
59
+ private timer;
60
+ private readonly setTimer;
61
+ private readonly clearTimer;
62
+ private readonly intervalMs;
63
+ private readonly manual;
64
+ pad: GamepadLike | null;
65
+ constructor(options?: MockGamepadOptions);
66
+ read(): readonly GamepadLike[];
67
+ start(onFrame: (pads: readonly GamepadLike[]) => void): void;
68
+ stop(): void;
69
+ /** Delivers one frame. The only way frames happen in manual mode. */
70
+ step(): void;
71
+ }
72
+ /** Builds a mock device with every axis at rest and every button up. */
73
+ export declare function mockPad(id: string, axisCount: number, buttonCount: number, overrides?: {
74
+ axes?: number[];
75
+ buttons?: number[];
76
+ index?: number;
77
+ }): GamepadLike;
78
+ //# sourceMappingURL=source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../../src/gamepad/source.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,KAAK,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,SAAS,WAAW,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC7D,iEAAiE;IACjE,IAAI,IAAI,IAAI,CAAC;IACb,iEAAiE;IACjE,IAAI,IAAI,SAAS,WAAW,EAAE,CAAC;IAC/B,6DAA6D;IAC7D,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,qBAAa,iBAAkB,YAAW,aAAa;IAOnD,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAP9B,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,OAAO,CAAyD;IAExE,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;gBAGT,YAAY,GAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,MAA0C,EAC5E,WAAW,GAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAqC;IAKzF,IAAI,IAAI,SAAS,WAAW,EAAE;IAO9B,KAAK,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,SAAS,WAAW,EAAE,KAAK,IAAI,GAAG,IAAI;IAU5D,IAAI,IAAI,IAAI;CAKb;AAED,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,GAAG,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IACzB,uEAAuE;IACvE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACnD,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CACxC;AAED;;;;;;GAMG;AACH,qBAAa,iBAAkB,YAAW,aAAa;IACrD,QAAQ,CAAC,SAAS,QAAQ;IAE1B,OAAO,CAAC,OAAO,CAAyD;IACxE,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA0C;IACnE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA4B;IACvD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAU;IAEjC,GAAG,EAAE,WAAW,GAAG,IAAI,CAAC;gBAEZ,OAAO,GAAE,kBAAuB;IAQ5C,IAAI,IAAI,SAAS,WAAW,EAAE;IAI9B,KAAK,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,SAAS,WAAW,EAAE,KAAK,IAAI,GAAG,IAAI;IAM5D,IAAI,IAAI,IAAI;IAMZ,qEAAqE;IACrE,IAAI,IAAI,IAAI;CAGb;AAED,wEAAwE;AACxE,wBAAgB,OAAO,CACrB,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,SAAS,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GACtE,WAAW,CAOb"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Reads the browser's Gamepad API.
3
+ *
4
+ * Polling is the only option the API offers: there are no per-frame events,
5
+ * and Chrome hands out a fresh snapshot object on every call. `getGamepads()`
6
+ * returns a sparse array whose holes are disconnected slots, hence the filter.
7
+ */
8
+ export class LiveGamepadSource {
9
+ requestFrame;
10
+ cancelFrame;
11
+ handle = null;
12
+ onFrame = null;
13
+ supported;
14
+ constructor(requestFrame = (cb) => requestAnimationFrame(cb), cancelFrame = (h) => cancelAnimationFrame(h)) {
15
+ this.requestFrame = requestFrame;
16
+ this.cancelFrame = cancelFrame;
17
+ this.supported = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function';
18
+ }
19
+ read() {
20
+ if (!this.supported)
21
+ return [];
22
+ return Array.from(navigator.getGamepads()).filter((pad) => Boolean(pad?.connected));
23
+ }
24
+ start(onFrame) {
25
+ this.onFrame = onFrame;
26
+ if (!this.supported || this.handle !== null)
27
+ return;
28
+ const tick = () => {
29
+ this.handle = this.requestFrame(tick);
30
+ this.onFrame?.(this.read());
31
+ };
32
+ this.handle = this.requestFrame(tick);
33
+ }
34
+ stop() {
35
+ if (this.handle !== null)
36
+ this.cancelFrame(this.handle);
37
+ this.handle = null;
38
+ this.onFrame = null;
39
+ }
40
+ }
41
+ /**
42
+ * A scripted device.
43
+ *
44
+ * Mutate `pad` between frames to drive a turn or a pedal. In manual mode the
45
+ * caller decides when a frame happens, so a test can assert on exactly the
46
+ * sequence it wrote rather than on whatever a timer produced.
47
+ */
48
+ export class MockGamepadSource {
49
+ supported = true;
50
+ onFrame = null;
51
+ timer = null;
52
+ setTimer;
53
+ clearTimer;
54
+ intervalMs;
55
+ manual;
56
+ pad;
57
+ constructor(options = {}) {
58
+ this.pad = options.pad ?? null;
59
+ this.manual = options.manual ?? false;
60
+ this.intervalMs = options.intervalMs ?? 16;
61
+ this.setTimer = options.setTimer ?? ((fn, ms) => setInterval(fn, ms));
62
+ this.clearTimer = options.clearTimer ?? ((h) => clearInterval(h));
63
+ }
64
+ read() {
65
+ return this.pad?.connected ? [this.pad] : [];
66
+ }
67
+ start(onFrame) {
68
+ this.onFrame = onFrame;
69
+ if (this.manual || this.timer !== null)
70
+ return;
71
+ this.timer = this.setTimer(() => this.step(), this.intervalMs);
72
+ }
73
+ stop() {
74
+ if (this.timer !== null)
75
+ this.clearTimer(this.timer);
76
+ this.timer = null;
77
+ this.onFrame = null;
78
+ }
79
+ /** Delivers one frame. The only way frames happen in manual mode. */
80
+ step() {
81
+ this.onFrame?.(this.read());
82
+ }
83
+ }
84
+ /** Builds a mock device with every axis at rest and every button up. */
85
+ export function mockPad(id, axisCount, buttonCount, overrides = {}) {
86
+ const axes = Array.from({ length: axisCount }, (_, i) => overrides.axes?.[i] ?? 0);
87
+ const buttons = Array.from({ length: buttonCount }, (_, i) => {
88
+ const value = overrides.buttons?.[i] ?? 0;
89
+ return { value, pressed: value > 0.5 };
90
+ });
91
+ return { id, index: overrides.index ?? 0, connected: true, mapping: '', axes, buttons };
92
+ }
package/package.json CHANGED
@@ -1,64 +1,68 @@
1
- {
2
- "name": "@realnation/builder-shared-sdk",
3
- "version": "2.5.1",
4
- "type": "module",
5
- "private": false,
6
- "exports": {
7
- "./package.json": "./package.json",
8
- ".": {
9
- "types": "./dist/index.d.ts",
10
- "import": "./dist/index.js"
11
- },
12
- "./runtime": {
13
- "types": "./dist/runtime/index.d.ts",
14
- "import": "./dist/runtime/index.js"
15
- },
16
- "./runtime/vue": {
17
- "types": "./dist/runtime/vue/index.d.ts",
18
- "import": "./dist/runtime/vue/index.js"
19
- },
20
- "./interaction": {
21
- "types": "./dist/interaction/index.d.ts",
22
- "import": "./dist/interaction/index.js"
23
- },
24
- "./interaction/vue": {
25
- "types": "./dist/interaction/vue.d.ts",
26
- "import": "./dist/interaction/vue.js"
27
- },
28
- "./souvenir": {
29
- "types": "./dist/souvenir/index.d.ts",
30
- "import": "./dist/souvenir/index.js"
31
- }
32
- },
33
- "files": [
34
- "dist"
35
- ],
36
- "scripts": {
37
- "build": "tsc -p tsconfig.json",
38
- "clean": "rimraf dist",
39
- "test": "vitest run",
40
- "test:watch": "vitest",
41
- "prepublishOnly": "npm run clean && npm run test && npm run build",
42
- "prepare": "npm run clean && npm run build"
43
- },
44
- "dependencies": {
45
- "ali-oss": "^6.23.0",
46
- "axios": "^1.7.7"
47
- },
48
- "peerDependencies": {
49
- "vue": "^3.4.0"
50
- },
51
- "peerDependenciesMeta": {
52
- "vue": {
53
- "optional": true
54
- }
55
- },
56
- "devDependencies": {
57
- "@types/ali-oss": "^6.16.11",
58
- "@types/node": "^26.1.2",
59
- "rimraf": "^5.0.5",
60
- "typescript": "^5.4.5",
61
- "vitest": "^2.1.0",
62
- "vue": "^3.4.0"
63
- }
64
- }
1
+ {
2
+ "name": "@realnation/builder-shared-sdk",
3
+ "version": "2.7.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "exports": {
7
+ "./package.json": "./package.json",
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./runtime": {
13
+ "types": "./dist/runtime/index.d.ts",
14
+ "import": "./dist/runtime/index.js"
15
+ },
16
+ "./runtime/vue": {
17
+ "types": "./dist/runtime/vue/index.d.ts",
18
+ "import": "./dist/runtime/vue/index.js"
19
+ },
20
+ "./interaction": {
21
+ "types": "./dist/interaction/index.d.ts",
22
+ "import": "./dist/interaction/index.js"
23
+ },
24
+ "./interaction/vue": {
25
+ "types": "./dist/interaction/vue.d.ts",
26
+ "import": "./dist/interaction/vue.js"
27
+ },
28
+ "./souvenir": {
29
+ "types": "./dist/souvenir/index.d.ts",
30
+ "import": "./dist/souvenir/index.js"
31
+ },
32
+ "./gamepad": {
33
+ "types": "./dist/gamepad/index.d.ts",
34
+ "import": "./dist/gamepad/index.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.json",
42
+ "clean": "rimraf dist",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
45
+ "prepublishOnly": "npm run clean && npm run test && npm run build",
46
+ "prepare": "npm run clean && npm run build"
47
+ },
48
+ "dependencies": {
49
+ "ali-oss": "^6.23.0",
50
+ "axios": "^1.7.7"
51
+ },
52
+ "peerDependencies": {
53
+ "vue": "^3.4.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "vue": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@types/ali-oss": "^6.16.11",
62
+ "@types/node": "^26.1.2",
63
+ "rimraf": "^5.0.5",
64
+ "typescript": "^5.4.5",
65
+ "vitest": "^2.1.0",
66
+ "vue": "^3.4.0"
67
+ }
68
+ }