@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.1

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -21
  3. package/README.md +191 -139
  4. package/dist/doctor/cli.mjs +49 -19
  5. package/dist/doctor/cli.mjs.map +1 -1
  6. package/dist/shared/client-version.gen.js +1 -1
  7. package/dist/shared/daily.js +1 -1
  8. package/dist/shared/globals.js +8 -3
  9. package/dist/shared/globals.js.map +1 -1
  10. package/dist/shared/index.d.ts +1 -1
  11. package/package.json +89 -89
  12. package/src/auto.ts +39 -39
  13. package/src/core/connection.ts +299 -299
  14. package/src/core/errors.ts +147 -147
  15. package/src/core/game-data.ts +38 -38
  16. package/src/core/hub-economy.ts +179 -179
  17. package/src/core/hub-progress.ts +205 -205
  18. package/src/core/hub.ts +131 -131
  19. package/src/core/menu-control.ts +61 -61
  20. package/src/core/offline/events.ts +50 -50
  21. package/src/core/offline/favorites.ts +21 -21
  22. package/src/core/offline/local-store.ts +261 -261
  23. package/src/core/offline/queue.ts +171 -171
  24. package/src/core/page.ts +74 -74
  25. package/src/core/rest/endpoint-types.ts +22 -22
  26. package/src/core/rest/rest-client.ts +152 -152
  27. package/src/core/storage/json-guards.ts +74 -74
  28. package/src/core/storage/key-value.ts +66 -66
  29. package/src/core/ticket.ts +43 -43
  30. package/src/core/types.ts +134 -134
  31. package/src/core/version/client-version.gen.ts +1 -1
  32. package/src/core/version/hub-version.ts +62 -62
  33. package/src/core/version/semver.ts +97 -97
  34. package/src/daily/daily-system.ts +161 -161
  35. package/src/daily/page-daily.ts +36 -36
  36. package/src/daily/types.ts +67 -67
  37. package/src/daily.ts +10 -10
  38. package/src/doctor/checks.ts +78 -76
  39. package/src/doctor/cli.ts +98 -106
  40. package/src/doctor/hub-version-fetch.ts +43 -0
  41. package/src/doctor/tsconfig.json +12 -12
  42. package/src/globals.ts +115 -110
  43. package/src/index.ts +50 -50
  44. package/src/input/device-state.ts +146 -146
  45. package/src/input/edges.ts +55 -55
  46. package/src/input/gamepad.ts +61 -61
  47. package/src/input/input-events.ts +32 -32
  48. package/src/input/input-registry.ts +164 -164
  49. package/src/input/input-system.ts +393 -393
  50. package/src/input/legacy-controls.ts +64 -64
  51. package/src/input/nav-state.ts +191 -191
  52. package/src/input/navigator.ts +171 -171
  53. package/src/input/on-screen-controls.ts +101 -101
  54. package/src/input/page-control.ts +43 -43
  55. package/src/input/page-input.ts +193 -193
  56. package/src/input/player-remaps.ts +77 -77
  57. package/src/input/source-values.ts +116 -116
  58. package/src/input/spatial.ts +47 -47
  59. package/src/input/types.ts +331 -331
  60. package/src/input/zero-config.ts +177 -177
  61. package/src/input.ts +22 -22
  62. package/src/mp.ts +10 -10
  63. package/src/overlay/controls-panel.ts +163 -163
  64. package/src/overlay/dom.ts +46 -46
  65. package/src/overlay/layout-clusters.ts +162 -162
  66. package/src/overlay/layout-geometry.ts +136 -136
  67. package/src/overlay/layout.ts +203 -203
  68. package/src/overlay/listener-scope.ts +23 -23
  69. package/src/overlay/on-screen-keyboard.ts +137 -137
  70. package/src/overlay/overlay.ts +208 -208
  71. package/src/overlay/styles.ts +9 -9
  72. package/src/overlay/virtual-controls.ts +262 -262
  73. package/src/race.ts +10 -10
  74. package/src/realtime/bridge.ts +121 -121
  75. package/src/realtime/cross-bundle.ts +29 -29
  76. package/src/realtime/fallback-notice.ts +48 -48
  77. package/src/realtime/hub-link.ts +65 -65
  78. package/src/realtime/latest-state.ts +33 -33
  79. package/src/realtime/multiplayer-forwarder.ts +28 -28
  80. package/src/realtime/multiplayer-types.ts +52 -52
  81. package/src/realtime/page-runtime.ts +60 -60
  82. package/src/realtime/race-forwarder.ts +59 -59
  83. package/src/realtime/race-types.ts +132 -132
  84. package/src/realtime/room-types.ts +125 -125
  85. package/src/realtime/rooms-forwarder.ts +79 -79
  86. package/src/realtime/runtime-api.ts +49 -49
  87. package/src/realtime/runtime-opener.ts +61 -61
  88. package/src/realtime/social-forwarder.ts +72 -72
  89. package/src/realtime/social-types.ts +78 -78
  90. package/src/realtime/types.ts +53 -53
  91. package/src/realtime/wire-types.ts +72 -72
  92. package/src/rooms.ts +10 -10
  93. package/src/social.ts +18 -18
  94. package/src/types.ts +13 -13
  95. package/src/uistack/page-stack.ts +45 -45
  96. package/src/uistack/play-suspension.ts +82 -82
  97. package/src/uistack/types.ts +76 -76
  98. package/src/uistack/ui-stack.ts +144 -144
package/src/globals.ts CHANGED
@@ -1,110 +1,115 @@
1
- /**
2
- * Page-wide singletons across client versions (plan §4.4). One page often
3
- * runs two client builds: the game's bundled copy and the host's injected
4
- * `/_hub/hub.js`. The input system, daily challenges and the realtime API
5
- * must exist once per page, so each is registered here with the version that
6
- * created it and a small `api` number that changes only when the
7
- * cross-bundle shape does.
8
- *
9
- * - Same `api`: the first creator wins and everyone shares it.
10
- * - Different `api`: the caller decides who owns the page (see
11
- * {@link claimSingleton}'s `onMismatch`); the mismatch is logged once with the fix.
12
- * - The v1 names (`window.__HUB_INPUT__`, `__HUB_DAILY__`, `__HUB_RT__`) stay as
13
- * aliases while vendored v1 copies are still around.
14
- */
15
-
16
- /** A page-wide singleton's name. */
17
- export type SingletonName = 'input' | 'daily' | 'rt';
18
-
19
- /** One registered singleton. */
20
- export interface SingletonRecord {
21
- /** The client version that created it. */
22
- readonly version: string;
23
- /** Its cross-bundle shape number. */
24
- readonly api: number;
25
- /** The instance itself; another bundle's object, so callers check it before use. */
26
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle on the page
27
- readonly instance: unknown;
28
- }
29
-
30
- /** The registry on `window.__DG__`. */
31
- export type DgGlobals = { [K in SingletonName]?: SingletonRecord };
32
-
33
- /** The v1 window names, kept as aliases. */
34
- export const LEGACY_GLOBAL_NAMES = { input: '__HUB_INPUT__', daily: '__HUB_DAILY__', rt: '__HUB_RT__' } as const satisfies Record<SingletonName, string>;
35
-
36
- declare global {
37
- interface Window {
38
- __DG__?: DgGlobals;
39
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
40
- __HUB_INPUT__?: unknown;
41
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
42
- __HUB_DAILY__?: unknown;
43
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
44
- __HUB_RT__?: unknown;
45
- }
46
- }
47
-
48
- /** How to claim a singleton. */
49
- export interface SingletonClaim<T> {
50
- readonly name: SingletonName;
51
- /** This bundle's client version. */
52
- readonly version: string;
53
- /** This bundle's shape number for the singleton. */
54
- readonly api: number;
55
- /** Make the instance when nobody has yet. */
56
- readonly create: () => T;
57
- /** Whether a registered instance is usable as `T` (it may come from another bundle). */
58
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: checks another bundle's object
59
- readonly isInstance: (candidate: unknown) => candidate is T;
60
- /**
61
- * The registered one has a different `api`: return `true` to replace it
62
- * with this bundle's instance, `false` to keep it (then `create` makes a
63
- * private instance for this bundle only). Default: keep it.
64
- */
65
- readonly onMismatch?: (registered: SingletonRecord) => boolean;
66
- }
67
-
68
- const warnedMismatches = new Set<SingletonName>();
69
-
70
- /** The page registry, created on first use (`undefined` outside a browser). */
71
- function registryOf(page: Window | undefined): DgGlobals | undefined {
72
- if (page === undefined) {
73
- return undefined;
74
- }
75
- page.__DG__ ??= {};
76
- return page.__DG__;
77
- }
78
-
79
- /** Register (or join) a page-wide singleton and return the instance this bundle should use. */
80
- export function claimSingleton<T>(claim: SingletonClaim<T>): T {
81
- const page = typeof window === 'undefined' ? undefined : window;
82
- const registry = registryOf(page);
83
- if (page === undefined || registry === undefined) {
84
- return claim.create();
85
- }
86
- const registered = registry[claim.name];
87
- if (registered !== undefined && claim.isInstance(registered.instance)) {
88
- if (registered.api === claim.api) {
89
- return registered.instance;
90
- }
91
- if (!warnedMismatches.has(claim.name)) {
92
- warnedMismatches.add(claim.name);
93
- // eslint-disable-next-line no-console -- the plan's one-time warning to the game developer, with the fix
94
- console.warn(`[hub] two clients on this page disagree on "${claim.name}" (v${registered.version} api ${String(registered.api)}, v${claim.version} api ${String(claim.api)}). Fix: npm update @diffenderfer-games/hub`);
95
- }
96
- if (claim.onMismatch?.(registered) !== true) {
97
- return claim.create();
98
- }
99
- } else {
100
- const legacy = page[LEGACY_GLOBAL_NAMES[claim.name]];
101
- if (registered === undefined && claim.isInstance(legacy)) {
102
- // A v1 vendored copy got there first: share it, as v1 bundles did.
103
- return legacy;
104
- }
105
- }
106
- const instance = claim.create();
107
- registry[claim.name] = { version: claim.version, api: claim.api, instance };
108
- page[LEGACY_GLOBAL_NAMES[claim.name]] = instance;
109
- return instance;
110
- }
1
+ /**
2
+ * Page-wide singletons across client versions (plan §4.4). One page often
3
+ * runs two client builds: the game's bundled copy and the host's injected
4
+ * `/_hub/hub.js`. The input system, daily challenges and the realtime API
5
+ * must exist once per page, so each is registered here with the version that
6
+ * created it and a small `api` number that changes only when the
7
+ * cross-bundle shape does.
8
+ *
9
+ * - Same `api`: the first creator wins and everyone shares it.
10
+ * - Different `api`: the caller decides who owns the page (see
11
+ * {@link claimSingleton}'s `onMismatch`); the mismatch is logged once with the fix.
12
+ * - The v1 names (`window.__HUB_INPUT__`, `__HUB_DAILY__`, `__HUB_RT__`) stay as
13
+ * aliases while vendored v1 copies are still around.
14
+ */
15
+
16
+ /** A page-wide singleton's name. */
17
+ export type SingletonName = 'input' | 'daily' | 'rt';
18
+
19
+ /** One registered singleton. */
20
+ export interface SingletonRecord {
21
+ /** The client version that created it. */
22
+ readonly version: string;
23
+ /** Its cross-bundle shape number. */
24
+ readonly api: number;
25
+ /** The instance itself; another bundle's object, so callers check it before use. */
26
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle on the page
27
+ readonly instance: unknown;
28
+ }
29
+
30
+ /** The registry on `window.__DG__`. */
31
+ export type DgGlobals = { [K in SingletonName]?: SingletonRecord };
32
+
33
+ /** The version recorded for a singleton adopted from a v1 (vendored) client, which predates the registry. */
34
+ export const V1_CLIENT_VERSION = 'v1';
35
+
36
+ /** The v1 window names, kept as aliases. */
37
+ export const LEGACY_GLOBAL_NAMES = { input: '__HUB_INPUT__', daily: '__HUB_DAILY__', rt: '__HUB_RT__' } as const satisfies Record<SingletonName, string>;
38
+
39
+ declare global {
40
+ interface Window {
41
+ __DG__?: DgGlobals;
42
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
43
+ __HUB_INPUT__?: unknown;
44
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
45
+ __HUB_DAILY__?: unknown;
46
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
47
+ __HUB_RT__?: unknown;
48
+ }
49
+ }
50
+
51
+ /** How to claim a singleton. */
52
+ export interface SingletonClaim<T> {
53
+ readonly name: SingletonName;
54
+ /** This bundle's client version. */
55
+ readonly version: string;
56
+ /** This bundle's shape number for the singleton. */
57
+ readonly api: number;
58
+ /** Make the instance when nobody has yet. */
59
+ readonly create: () => T;
60
+ /** Whether a registered instance is usable as `T` (it may come from another bundle). */
61
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: checks another bundle's object
62
+ readonly isInstance: (candidate: unknown) => candidate is T;
63
+ /**
64
+ * The registered one has a different `api`: return `true` to replace it
65
+ * with this bundle's instance, `false` to keep it (then `create` makes a
66
+ * private instance for this bundle only). Default: keep it.
67
+ */
68
+ readonly onMismatch?: (registered: SingletonRecord) => boolean;
69
+ }
70
+
71
+ const warnedMismatches = new Set<SingletonName>();
72
+
73
+ /** The page registry, created on first use (`undefined` outside a browser). */
74
+ function registryOf(page: Window | undefined): DgGlobals | undefined {
75
+ if (page === undefined) {
76
+ return undefined;
77
+ }
78
+ page.__DG__ ??= {};
79
+ return page.__DG__;
80
+ }
81
+
82
+ /** Register (or join) a page-wide singleton and return the instance this bundle should use. */
83
+ export function claimSingleton<T>(claim: SingletonClaim<T>): T {
84
+ const page = typeof window === 'undefined' ? undefined : window;
85
+ const registry = registryOf(page);
86
+ if (page === undefined || registry === undefined) {
87
+ return claim.create();
88
+ }
89
+ const registered = registry[claim.name];
90
+ if (registered !== undefined && claim.isInstance(registered.instance)) {
91
+ if (registered.api === claim.api) {
92
+ return registered.instance;
93
+ }
94
+ if (!warnedMismatches.has(claim.name)) {
95
+ warnedMismatches.add(claim.name);
96
+ // eslint-disable-next-line no-console -- the plan's one-time warning to the game developer, with the fix
97
+ console.warn(`[hub] two clients on this page disagree on "${claim.name}" (v${registered.version} api ${String(registered.api)}, v${claim.version} api ${String(claim.api)}). Fix: npm update @diffenderfer-games/hub`);
98
+ }
99
+ if (claim.onMismatch?.(registered) !== true) {
100
+ return claim.create();
101
+ }
102
+ } else {
103
+ const legacy = page[LEGACY_GLOBAL_NAMES[claim.name]];
104
+ if (registered === undefined && claim.isInstance(legacy)) {
105
+ // A v1 vendored copy got there first: share it, as v1 bundles did, and
106
+ // record it so later bundles (and diagnostics) see which client owns it.
107
+ registry[claim.name] = { version: V1_CLIENT_VERSION, api: claim.api, instance: legacy };
108
+ return legacy;
109
+ }
110
+ }
111
+ const instance = claim.create();
112
+ registry[claim.name] = { version: claim.version, api: claim.api, instance };
113
+ page[LEGACY_GLOBAL_NAMES[claim.name]] = instance;
114
+ return instance;
115
+ }
package/src/index.ts CHANGED
@@ -1,50 +1,50 @@
1
- /**
2
- * `@diffenderfer-games/hub`: the hub client for games on diffenderfer.games.
3
- *
4
- * ```ts
5
- * import { hub } from '@diffenderfer-games/hub';
6
- *
7
- * const { user } = await hub.me();
8
- * await hub.putSave('auto', { level: 7, gold: 120 });
9
- * await hub.submitScore('highscore', 9001, { title: 'High Score' });
10
- * ```
11
- *
12
- * In a hosted game the slug and API base are detected from the page; use
13
- * {@link createHub} for others. Every call rejects with a {@link HubError}
14
- * when it fails, except the fire-and-forget analytics (`recordPlay`,
15
- * `heartbeat`, `event`). When the hub can't be reached, reads come from an
16
- * offline cache and writes are queued until it is back.
17
- *
18
- * The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
19
- * `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
20
- * its own entry (`@diffenderfer-games/hub/input`, ...) for games that want
21
- * only that part.
22
- *
23
- * @packageDocumentation
24
- */
25
- export { createHub, Hub, hub } from './core/hub';
26
- export {
27
- ContentRejectedError,
28
- HubError,
29
- OfflineError,
30
- UNSUPPORTED_BY_HUB,
31
- type HubErrorCode,
32
- type HubErrorDetails,
33
- } from './core/errors';
34
- export type { SyncConflict, SyncResult } from './core/connection';
35
- export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progress';
36
- export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
37
- export type { HubMenuControl } from './core/menu-control';
38
- export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
39
- export type { BufferedEvent } from './core/offline/events';
40
- export type { EndpointFields, EndpointName, EndpointReply } from './core/rest/endpoint-types';
41
- export { CLIENT_VERSION_HEADER } from './core/rest/rest-client';
42
- export type { ClientSupport } from './core/version/hub-version';
43
- export { HUB_CLIENT_VERSION } from './core/version/client-version.gen';
44
- export type * from './core/types';
45
- export * from './daily';
46
- export * from './input';
47
- export type * from './social';
48
- export type * from './mp';
49
- export type * from './race';
50
- export type * from './rooms';
1
+ /**
2
+ * `@diffenderfer-games/hub`: the hub client for games on diffenderfer.games.
3
+ *
4
+ * ```ts
5
+ * import { hub } from '@diffenderfer-games/hub';
6
+ *
7
+ * const { user } = await hub.me();
8
+ * await hub.putSave('auto', { level: 7, gold: 120 });
9
+ * await hub.submitScore('highscore', 9001, { title: 'High Score' });
10
+ * ```
11
+ *
12
+ * In a hosted game the slug and API base are detected from the page; use
13
+ * {@link createHub} for others. Every call rejects with a {@link HubError}
14
+ * when it fails, except the fire-and-forget analytics (`recordPlay`,
15
+ * `heartbeat`, `event`). When the hub can't be reached, reads come from an
16
+ * offline cache and writes are queued until it is back.
17
+ *
18
+ * The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
19
+ * `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
20
+ * its own entry (`@diffenderfer-games/hub/input`, ...) for games that want
21
+ * only that part.
22
+ *
23
+ * @packageDocumentation
24
+ */
25
+ export { createHub, Hub, hub } from './core/hub';
26
+ export {
27
+ ContentRejectedError,
28
+ HubError,
29
+ OfflineError,
30
+ UNSUPPORTED_BY_HUB,
31
+ type HubErrorCode,
32
+ type HubErrorDetails,
33
+ } from './core/errors';
34
+ export type { SyncConflict, SyncResult } from './core/connection';
35
+ export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progress';
36
+ export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
37
+ export type { HubMenuControl } from './core/menu-control';
38
+ export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
39
+ export type { BufferedEvent } from './core/offline/events';
40
+ export type { EndpointFields, EndpointName, EndpointReply } from './core/rest/endpoint-types';
41
+ export { CLIENT_VERSION_HEADER } from './core/rest/rest-client';
42
+ export type { ClientSupport } from './core/version/hub-version';
43
+ export { HUB_CLIENT_VERSION } from './core/version/client-version.gen';
44
+ export type * from './core/types';
45
+ export * from './daily';
46
+ export * from './input';
47
+ export type * from './social';
48
+ export type * from './mp';
49
+ export type * from './race';
50
+ export type * from './rooms';
@@ -1,146 +1,146 @@
1
- /**
2
- * The raw state of every input device, updated by DOM events and sampled once
3
- * per frame: held keys and mouse buttons, the pointer, movement and wheel
4
- * accumulators, one-frame pulses, on-screen sticks and buttons, and gamepads.
5
- */
6
- import { AXIS_DEADZONE, readGamepads } from './gamepad';
7
-
8
- /** An on-screen stick's output, -1..1 per axis. */
9
- export interface StickOutput {
10
- outX: number;
11
- outY: number;
12
- }
13
-
14
- /** A 2D accumulator. */
15
- interface Delta {
16
- dx: number;
17
- dy: number;
18
- }
19
-
20
- /** Keys that scroll the page; cancelled during play (not while typing). */
21
- const SCROLL_KEYS = new Set([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright']);
22
-
23
- /** Input types that don't take text (buttons, checkboxes, sliders…). */
24
- const NON_TEXT_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'checkbox', 'radio', 'range', 'color', 'file', 'image']);
25
-
26
- /** Whether typing in `target` owns Space and the arrow keys. */
27
- export function isTypingTarget(target: EventTarget | null): boolean {
28
- if (!(target instanceof HTMLElement)) {
29
- return false;
30
- }
31
- if (target.isContentEditable || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {
32
- return true;
33
- }
34
- return target instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(target.type);
35
- }
36
-
37
- /** What the device state reports back to the input system. */
38
- export interface DeviceStateEvents {
39
- /** The mouse moved or clicked: it is the active pointer. */
40
- onMouseActivity(): void;
41
- /** A gamepad connected (`true`) or disconnected (`false`). */
42
- onGamepadConnection(isConnected: boolean): void;
43
- /** Whether keydown should cancel scrolling keys (a game defined its inputs). */
44
- shouldCancelScrollKeys(): boolean;
45
- }
46
-
47
- /** Raw device state for one page. */
48
- export class DeviceState {
49
- readonly heldKeys = new Set<string>();
50
- readonly heldMouseButtons = new Set<number>();
51
- readonly pressedVirtualButtons = new Set<string>();
52
- readonly sticks = new Map<string, StickOutput>();
53
- pointerX = 0;
54
- pointerY = 0;
55
- readonly movement: Delta = { dx: 0, dy: 0 };
56
- readonly wheel: Delta = { dx: 0, dy: 0 };
57
- gamepads: Gamepad[] = [];
58
- frame = 0;
59
- private readonly pulseFrames = new Map<string, number>();
60
-
61
- /** Fire a one-frame pulse (`tap`, `mouse:dbl`). */
62
- pulse(name: string): void {
63
- this.pulseFrames.set(name, this.frame);
64
- }
65
-
66
- /** Whether a pulse fired this frame or the last. */
67
- isPulseActive(name: string): boolean {
68
- const firedAt = this.pulseFrames.get(name);
69
- return firedAt === this.frame || firedAt === this.frame - 1;
70
- }
71
-
72
- /** Poll the gamepads. Returns whether any are connected and whether any moved. */
73
- pollGamepads(): { isConnected: boolean; hasActivity: boolean } {
74
- this.gamepads = readGamepads();
75
- const hasActivity = this.gamepads.some((pad) => pad.buttons.some((button) => button.pressed || button.value > 0.5)
76
- || pad.axes.some((axis) => Math.abs(axis) > AXIS_DEADZONE));
77
- return { isConnected: this.gamepads.length > 0, hasActivity };
78
- }
79
-
80
- /** End-of-frame decay of the relative accumulators. */
81
- decay(): void {
82
- this.movement.dx = 0;
83
- this.movement.dy = 0;
84
- this.wheel.dx *= 0.6;
85
- this.wheel.dy *= 0.6;
86
- }
87
-
88
- /** Release everything held (focus loss, hidden tab), so nothing sticks. */
89
- releaseAll(): void {
90
- this.heldKeys.clear();
91
- this.heldMouseButtons.clear();
92
- this.pressedVirtualButtons.clear();
93
- for (const stick of this.sticks.values()) {
94
- stick.outX = 0;
95
- stick.outY = 0;
96
- }
97
- }
98
-
99
- /** Wire the DOM listeners that feed this state. */
100
- listen(events: DeviceStateEvents): void {
101
- if (typeof window === 'undefined') {
102
- return;
103
- }
104
- window.addEventListener('keydown', (event) => {
105
- const key = event.key.toLowerCase();
106
- this.heldKeys.add(key);
107
- if (events.shouldCancelScrollKeys() && SCROLL_KEYS.has(key) && !isTypingTarget(event.target)) {
108
- event.preventDefault();
109
- }
110
- });
111
- window.addEventListener('keyup', (event) => this.heldKeys.delete(event.key.toLowerCase()));
112
- window.addEventListener('mousemove', (event) => {
113
- this.movement.dx += event.movementX;
114
- this.movement.dy += event.movementY;
115
- this.pointerX = event.clientX;
116
- this.pointerY = event.clientY;
117
- events.onMouseActivity();
118
- });
119
- window.addEventListener('mousedown', (event) => {
120
- this.heldMouseButtons.add(event.button);
121
- events.onMouseActivity();
122
- });
123
- window.addEventListener('mouseup', (event) => this.heldMouseButtons.delete(event.button));
124
- window.addEventListener('dblclick', () => {
125
- this.pulse('mouse:dbl');
126
- });
127
- window.addEventListener('wheel', (event) => {
128
- this.wheel.dx += Math.sign(event.deltaX);
129
- this.wheel.dy += Math.sign(event.deltaY);
130
- }, { passive: true });
131
- window.addEventListener('blur', () => {
132
- this.releaseAll();
133
- });
134
- document.addEventListener('visibilitychange', () => {
135
- if (document.hidden) {
136
- this.releaseAll();
137
- }
138
- });
139
- window.addEventListener('gamepadconnected', () => {
140
- events.onGamepadConnection(true);
141
- });
142
- window.addEventListener('gamepaddisconnected', () => {
143
- events.onGamepadConnection(false);
144
- });
145
- }
146
- }
1
+ /**
2
+ * The raw state of every input device, updated by DOM events and sampled once
3
+ * per frame: held keys and mouse buttons, the pointer, movement and wheel
4
+ * accumulators, one-frame pulses, on-screen sticks and buttons, and gamepads.
5
+ */
6
+ import { AXIS_DEADZONE, readGamepads } from './gamepad';
7
+
8
+ /** An on-screen stick's output, -1..1 per axis. */
9
+ export interface StickOutput {
10
+ outX: number;
11
+ outY: number;
12
+ }
13
+
14
+ /** A 2D accumulator. */
15
+ interface Delta {
16
+ dx: number;
17
+ dy: number;
18
+ }
19
+
20
+ /** Keys that scroll the page; cancelled during play (not while typing). */
21
+ const SCROLL_KEYS = new Set([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright']);
22
+
23
+ /** Input types that don't take text (buttons, checkboxes, sliders…). */
24
+ const NON_TEXT_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'checkbox', 'radio', 'range', 'color', 'file', 'image']);
25
+
26
+ /** Whether typing in `target` owns Space and the arrow keys. */
27
+ export function isTypingTarget(target: EventTarget | null): boolean {
28
+ if (!(target instanceof HTMLElement)) {
29
+ return false;
30
+ }
31
+ if (target.isContentEditable || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {
32
+ return true;
33
+ }
34
+ return target instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(target.type);
35
+ }
36
+
37
+ /** What the device state reports back to the input system. */
38
+ export interface DeviceStateEvents {
39
+ /** The mouse moved or clicked: it is the active pointer. */
40
+ onMouseActivity(): void;
41
+ /** A gamepad connected (`true`) or disconnected (`false`). */
42
+ onGamepadConnection(isConnected: boolean): void;
43
+ /** Whether keydown should cancel scrolling keys (a game defined its inputs). */
44
+ shouldCancelScrollKeys(): boolean;
45
+ }
46
+
47
+ /** Raw device state for one page. */
48
+ export class DeviceState {
49
+ readonly heldKeys = new Set<string>();
50
+ readonly heldMouseButtons = new Set<number>();
51
+ readonly pressedVirtualButtons = new Set<string>();
52
+ readonly sticks = new Map<string, StickOutput>();
53
+ pointerX = 0;
54
+ pointerY = 0;
55
+ readonly movement: Delta = { dx: 0, dy: 0 };
56
+ readonly wheel: Delta = { dx: 0, dy: 0 };
57
+ gamepads: Gamepad[] = [];
58
+ frame = 0;
59
+ private readonly pulseFrames = new Map<string, number>();
60
+
61
+ /** Fire a one-frame pulse (`tap`, `mouse:dbl`). */
62
+ pulse(name: string): void {
63
+ this.pulseFrames.set(name, this.frame);
64
+ }
65
+
66
+ /** Whether a pulse fired this frame or the last. */
67
+ isPulseActive(name: string): boolean {
68
+ const firedAt = this.pulseFrames.get(name);
69
+ return firedAt === this.frame || firedAt === this.frame - 1;
70
+ }
71
+
72
+ /** Poll the gamepads. Returns whether any are connected and whether any moved. */
73
+ pollGamepads(): { isConnected: boolean; hasActivity: boolean } {
74
+ this.gamepads = readGamepads();
75
+ const hasActivity = this.gamepads.some((pad) => pad.buttons.some((button) => button.pressed || button.value > 0.5)
76
+ || pad.axes.some((axis) => Math.abs(axis) > AXIS_DEADZONE));
77
+ return { isConnected: this.gamepads.length > 0, hasActivity };
78
+ }
79
+
80
+ /** End-of-frame decay of the relative accumulators. */
81
+ decay(): void {
82
+ this.movement.dx = 0;
83
+ this.movement.dy = 0;
84
+ this.wheel.dx *= 0.6;
85
+ this.wheel.dy *= 0.6;
86
+ }
87
+
88
+ /** Release everything held (focus loss, hidden tab), so nothing sticks. */
89
+ releaseAll(): void {
90
+ this.heldKeys.clear();
91
+ this.heldMouseButtons.clear();
92
+ this.pressedVirtualButtons.clear();
93
+ for (const stick of this.sticks.values()) {
94
+ stick.outX = 0;
95
+ stick.outY = 0;
96
+ }
97
+ }
98
+
99
+ /** Wire the DOM listeners that feed this state. */
100
+ listen(events: DeviceStateEvents): void {
101
+ if (typeof window === 'undefined') {
102
+ return;
103
+ }
104
+ window.addEventListener('keydown', (event) => {
105
+ const key = event.key.toLowerCase();
106
+ this.heldKeys.add(key);
107
+ if (events.shouldCancelScrollKeys() && SCROLL_KEYS.has(key) && !isTypingTarget(event.target)) {
108
+ event.preventDefault();
109
+ }
110
+ });
111
+ window.addEventListener('keyup', (event) => this.heldKeys.delete(event.key.toLowerCase()));
112
+ window.addEventListener('mousemove', (event) => {
113
+ this.movement.dx += event.movementX;
114
+ this.movement.dy += event.movementY;
115
+ this.pointerX = event.clientX;
116
+ this.pointerY = event.clientY;
117
+ events.onMouseActivity();
118
+ });
119
+ window.addEventListener('mousedown', (event) => {
120
+ this.heldMouseButtons.add(event.button);
121
+ events.onMouseActivity();
122
+ });
123
+ window.addEventListener('mouseup', (event) => this.heldMouseButtons.delete(event.button));
124
+ window.addEventListener('dblclick', () => {
125
+ this.pulse('mouse:dbl');
126
+ });
127
+ window.addEventListener('wheel', (event) => {
128
+ this.wheel.dx += Math.sign(event.deltaX);
129
+ this.wheel.dy += Math.sign(event.deltaY);
130
+ }, { passive: true });
131
+ window.addEventListener('blur', () => {
132
+ this.releaseAll();
133
+ });
134
+ document.addEventListener('visibilitychange', () => {
135
+ if (document.hidden) {
136
+ this.releaseAll();
137
+ }
138
+ });
139
+ window.addEventListener('gamepadconnected', () => {
140
+ events.onGamepadConnection(true);
141
+ });
142
+ window.addEventListener('gamepaddisconnected', () => {
143
+ events.onGamepadConnection(false);
144
+ });
145
+ }
146
+ }