@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
@@ -1,193 +1,193 @@
1
- /**
2
- * The page's one input system (plan §4.4). The game's copy of the client owns
3
- * the page because the game defines the controls; the host menu reaches it
4
- * through the stable {@link PageInputControl} slice. Remaps persist to
5
- * localStorage (offline-safe) and, through the core client, to a reserved
6
- * cloud save slot.
7
- */
8
- import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
9
- import { claimSingleton, LEGACY_GLOBAL_NAMES } from '../globals';
10
- import { hasMethods, isObject, readField } from '../realtime/cross-bundle';
11
- import { InputSystem } from './input-system';
12
- import { isPageInputControl } from './page-control';
13
- import type { PageInputControl } from './page-control';
14
- import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
15
-
16
- /** The cross-bundle shape number of the `input` singleton. Bump only on an incompatible change. */
17
- export const INPUT_SINGLETON_API = 1;
18
-
19
- /** The public methods of every input system, v1 copies included. */
20
- const INPUT_SYSTEM_METHODS = [
21
- 'define', 'enable', 'disable', 'isEnabled', 'value', 'get', 'down', 'up', 'pressed', 'axis', 'vector', 'on',
22
- 'setVirtualHtml', 'showVirtual', 'setInsets', 'autoNavigate', 'clearAutoNavigate', 'attachMenu', 'setLegacyControls',
23
- 'openControls', 'rebind', 'setVirtual',
24
- ] as const;
25
-
26
- /** Whether `candidate` is some bundle's input system (v1 copies included). */
27
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
28
- export function isInputSystem(candidate: unknown): candidate is InputSystem {
29
- return hasMethods(candidate, INPUT_SYSTEM_METHODS);
30
- }
31
-
32
- /** How the core client connects the input system to its game. */
33
- export interface InputOptions {
34
- /** This page's game slug (default: `window.__HUB__.slug`). */
35
- slug?: string | null;
36
- /** Mirror remaps to the cloud (the core client writes the `__controls` save slot). */
37
- saveToCloud?: (overrides: InputOverrides) => void;
38
- }
39
-
40
- /** A finite number, or `undefined`. */
41
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
42
- function finiteNumber(candidate: unknown): number | undefined {
43
- return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
44
- }
45
-
46
- /** A saved rebind: the keys or gamepad button the player chose. */
47
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
48
- function parseBinding(saved: unknown): Partial<InputDef> {
49
- const binding: Partial<InputDef> = {};
50
- const keys = readField(saved, 'keys');
51
- if (Array.isArray(keys) && keys.every((key) => typeof key === 'string')) {
52
- binding.keys = keys.filter((key): key is string => typeof key === 'string');
53
- }
54
- const button = finiteNumber(readField(readField(saved, 'gamepad'), 'button'));
55
- if (button !== undefined) {
56
- binding.gamepad = { button };
57
- }
58
- return binding;
59
- }
60
-
61
- /** A saved dragged spot. */
62
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
63
- function parseVirtualOverride(saved: unknown): VirtualOverride {
64
- const spot: VirtualOverride = {};
65
- const left = finiteNumber(readField(saved, 'x'));
66
- const top = finiteNumber(readField(saved, 'y'));
67
- if (left !== undefined) {
68
- spot.x = left;
69
- }
70
- if (top !== undefined) {
71
- spot.y = top;
72
- }
73
- return spot;
74
- }
75
-
76
- /** Entries of a saved record, parsed. */
77
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
78
- function parseRecord<Parsed>(saved: unknown, parseEntry: (entry: unknown) => Parsed): Record<string, Parsed> | undefined {
79
- if (!isObject(saved)) {
80
- return undefined;
81
- }
82
- return Object.fromEntries(Object.entries(saved).map(([key, entry]) => [key, parseEntry(entry)]));
83
- }
84
-
85
- /** Saved overrides from JSON, keeping only well-formed fields. */
86
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from localStorage
87
- export function parseInputOverrides(saved: unknown): InputOverrides {
88
- const overrides: InputOverrides = {};
89
- const bindings = parseRecord(readField(saved, 'bindings'), parseBinding);
90
- const virtual = parseRecord(readField(saved, 'virtual'), parseVirtualOverride);
91
- const showVirtual = readField(saved, 'showVirtual');
92
- if (bindings !== undefined) {
93
- overrides.bindings = bindings;
94
- }
95
- if (virtual !== undefined) {
96
- overrides.virtual = virtual;
97
- }
98
- if (typeof showVirtual === 'boolean') {
99
- overrides.showVirtual = showVirtual;
100
- }
101
- return overrides;
102
- }
103
-
104
- /** A field of the host's injected `window.__HUB__`. */
105
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the host page's injected config, checked by the caller
106
- function injectedField(key: string): unknown {
107
- return typeof window === 'undefined' ? undefined : readField(readField(window, '__HUB__'), key);
108
- }
109
-
110
- /** The slug the host injected, or `null`. */
111
- function injectedSlug(): string | null {
112
- const slug = injectedField('slug');
113
- return typeof slug === 'string' ? slug : null;
114
- }
115
-
116
- /** A new input system, starting from the host's injected `game.controls` (the menu may pass them again later). */
117
- function createInputSystem(options: InputOptions, isPageOwner: boolean): InputSystem {
118
- const system = new InputSystem(createInputHost(options), { isPageOwner, claimPage: registerAsPageInput });
119
- system.setLegacyControls(injectedField('controls') ?? null);
120
- return system;
121
- }
122
-
123
- /** Persistence for remaps: localStorage under `hub:<slug>:input`, mirrored to the cloud when given. */
124
- function createInputHost(options: InputOptions): InputHost {
125
- const slug = options.slug === undefined ? injectedSlug() : options.slug;
126
- const storageKey = `hub:${slug ?? 'null'}:input`;
127
- return {
128
- slug,
129
- loadOverrides() {
130
- try {
131
- const saved = localStorage.getItem(storageKey);
132
- if (saved === null) {
133
- return null;
134
- }
135
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON from localStorage, parsed straight away
136
- const parsed: unknown = JSON.parse(saved);
137
- return parseInputOverrides(parsed);
138
- } catch {
139
- return null;
140
- }
141
- },
142
- saveOverrides(overrides) {
143
- try {
144
- localStorage.setItem(storageKey, JSON.stringify(overrides));
145
- } catch {
146
- // Storage full or blocked: the cloud copy still saves.
147
- }
148
- if (slug !== null) {
149
- options.saveToCloud?.(overrides);
150
- }
151
- },
152
- };
153
- }
154
-
155
- /** Make `system` the page's registered input system; returns the one it replaces. */
156
- function registerAsPageInput(system: InputSystem): PageInputControl | null {
157
- const registry = window.__DG__ ?? {};
158
- window.__DG__ = registry;
159
- const previous = registry.input?.instance ?? window[LEGACY_GLOBAL_NAMES.input];
160
- registry.input = { version: HUB_CLIENT_VERSION, api: INPUT_SINGLETON_API, instance: system };
161
- window[LEGACY_GLOBAL_NAMES.input] = system;
162
- return previous !== system && isPageInputControl(previous) ? previous : null;
163
- }
164
-
165
- /** Whether another bundle registered an input system with a different shape number. */
166
- function hasMismatchedPageInput(): boolean {
167
- const registered = typeof window === 'undefined' ? undefined : window.__DG__?.input;
168
- return registered !== undefined && registered.api !== INPUT_SINGLETON_API && isInputSystem(registered.instance);
169
- }
170
-
171
- let claimedInput: InputSystem | null = null;
172
-
173
- /**
174
- * The page-wide input system, created on first use. `options` connect it to
175
- * the core client (its slug, the cloud save); the first call's options win.
176
- * When another client copy with a different shape owns the page, this copy
177
- * gets a private instance that takes the page over once its game defines
178
- * inputs.
179
- */
180
- export function getInput(options: InputOptions = {}): InputSystem {
181
- if (claimedInput !== null) {
182
- return claimedInput;
183
- }
184
- const isPrivate = hasMismatchedPageInput();
185
- claimedInput = claimSingleton({
186
- name: 'input',
187
- version: HUB_CLIENT_VERSION,
188
- api: INPUT_SINGLETON_API,
189
- create: () => createInputSystem(options, !isPrivate),
190
- isInstance: isInputSystem,
191
- });
192
- return claimedInput;
193
- }
1
+ /**
2
+ * The page's one input system (plan §4.4). The game's copy of the client owns
3
+ * the page because the game defines the controls; the host menu reaches it
4
+ * through the stable {@link PageInputControl} slice. Remaps persist to
5
+ * localStorage (offline-safe) and, through the core client, to a reserved
6
+ * cloud save slot.
7
+ */
8
+ import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
9
+ import { claimSingleton, LEGACY_GLOBAL_NAMES } from '../globals';
10
+ import { hasMethods, isObject, readField } from '../realtime/cross-bundle';
11
+ import { InputSystem } from './input-system';
12
+ import { isPageInputControl } from './page-control';
13
+ import type { PageInputControl } from './page-control';
14
+ import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
15
+
16
+ /** The cross-bundle shape number of the `input` singleton. Bump only on an incompatible change. */
17
+ export const INPUT_SINGLETON_API = 1;
18
+
19
+ /** The public methods of every input system, v1 copies included. */
20
+ const INPUT_SYSTEM_METHODS = [
21
+ 'define', 'enable', 'disable', 'isEnabled', 'value', 'get', 'down', 'up', 'pressed', 'axis', 'vector', 'on',
22
+ 'setVirtualHtml', 'showVirtual', 'setInsets', 'autoNavigate', 'clearAutoNavigate', 'attachMenu', 'setLegacyControls',
23
+ 'openControls', 'rebind', 'setVirtual',
24
+ ] as const;
25
+
26
+ /** Whether `candidate` is some bundle's input system (v1 copies included). */
27
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
28
+ export function isInputSystem(candidate: unknown): candidate is InputSystem {
29
+ return hasMethods(candidate, INPUT_SYSTEM_METHODS);
30
+ }
31
+
32
+ /** How the core client connects the input system to its game. */
33
+ export interface InputOptions {
34
+ /** This page's game slug (default: `window.__HUB__.slug`). */
35
+ slug?: string | null;
36
+ /** Mirror remaps to the cloud (the core client writes the `__controls` save slot). */
37
+ saveToCloud?: (overrides: InputOverrides) => void;
38
+ }
39
+
40
+ /** A finite number, or `undefined`. */
41
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
42
+ function finiteNumber(candidate: unknown): number | undefined {
43
+ return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
44
+ }
45
+
46
+ /** A saved rebind: the keys or gamepad button the player chose. */
47
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
48
+ function parseBinding(saved: unknown): Partial<InputDef> {
49
+ const binding: Partial<InputDef> = {};
50
+ const keys = readField(saved, 'keys');
51
+ if (Array.isArray(keys) && keys.every((key) => typeof key === 'string')) {
52
+ binding.keys = keys.filter((key): key is string => typeof key === 'string');
53
+ }
54
+ const button = finiteNumber(readField(readField(saved, 'gamepad'), 'button'));
55
+ if (button !== undefined) {
56
+ binding.gamepad = { button };
57
+ }
58
+ return binding;
59
+ }
60
+
61
+ /** A saved dragged spot. */
62
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
63
+ function parseVirtualOverride(saved: unknown): VirtualOverride {
64
+ const spot: VirtualOverride = {};
65
+ const left = finiteNumber(readField(saved, 'x'));
66
+ const top = finiteNumber(readField(saved, 'y'));
67
+ if (left !== undefined) {
68
+ spot.x = left;
69
+ }
70
+ if (top !== undefined) {
71
+ spot.y = top;
72
+ }
73
+ return spot;
74
+ }
75
+
76
+ /** Entries of a saved record, parsed. */
77
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
78
+ function parseRecord<Parsed>(saved: unknown, parseEntry: (entry: unknown) => Parsed): Record<string, Parsed> | undefined {
79
+ if (!isObject(saved)) {
80
+ return undefined;
81
+ }
82
+ return Object.fromEntries(Object.entries(saved).map(([key, entry]) => [key, parseEntry(entry)]));
83
+ }
84
+
85
+ /** Saved overrides from JSON, keeping only well-formed fields. */
86
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from localStorage
87
+ export function parseInputOverrides(saved: unknown): InputOverrides {
88
+ const overrides: InputOverrides = {};
89
+ const bindings = parseRecord(readField(saved, 'bindings'), parseBinding);
90
+ const virtual = parseRecord(readField(saved, 'virtual'), parseVirtualOverride);
91
+ const showVirtual = readField(saved, 'showVirtual');
92
+ if (bindings !== undefined) {
93
+ overrides.bindings = bindings;
94
+ }
95
+ if (virtual !== undefined) {
96
+ overrides.virtual = virtual;
97
+ }
98
+ if (typeof showVirtual === 'boolean') {
99
+ overrides.showVirtual = showVirtual;
100
+ }
101
+ return overrides;
102
+ }
103
+
104
+ /** A field of the host's injected `window.__HUB__`. */
105
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the host page's injected config, checked by the caller
106
+ function injectedField(key: string): unknown {
107
+ return typeof window === 'undefined' ? undefined : readField(readField(window, '__HUB__'), key);
108
+ }
109
+
110
+ /** The slug the host injected, or `null`. */
111
+ function injectedSlug(): string | null {
112
+ const slug = injectedField('slug');
113
+ return typeof slug === 'string' ? slug : null;
114
+ }
115
+
116
+ /** A new input system, starting from the host's injected `game.controls` (the menu may pass them again later). */
117
+ function createInputSystem(options: InputOptions, isPageOwner: boolean): InputSystem {
118
+ const system = new InputSystem(createInputHost(options), { isPageOwner, claimPage: registerAsPageInput });
119
+ system.setLegacyControls(injectedField('controls') ?? null);
120
+ return system;
121
+ }
122
+
123
+ /** Persistence for remaps: localStorage under `hub:<slug>:input`, mirrored to the cloud when given. */
124
+ function createInputHost(options: InputOptions): InputHost {
125
+ const slug = options.slug === undefined ? injectedSlug() : options.slug;
126
+ const storageKey = `hub:${slug ?? 'null'}:input`;
127
+ return {
128
+ slug,
129
+ loadOverrides() {
130
+ try {
131
+ const saved = localStorage.getItem(storageKey);
132
+ if (saved === null) {
133
+ return null;
134
+ }
135
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON from localStorage, parsed straight away
136
+ const parsed: unknown = JSON.parse(saved);
137
+ return parseInputOverrides(parsed);
138
+ } catch {
139
+ return null;
140
+ }
141
+ },
142
+ saveOverrides(overrides) {
143
+ try {
144
+ localStorage.setItem(storageKey, JSON.stringify(overrides));
145
+ } catch {
146
+ // Storage full or blocked: the cloud copy still saves.
147
+ }
148
+ if (slug !== null) {
149
+ options.saveToCloud?.(overrides);
150
+ }
151
+ },
152
+ };
153
+ }
154
+
155
+ /** Make `system` the page's registered input system; returns the one it replaces. */
156
+ function registerAsPageInput(system: InputSystem): PageInputControl | null {
157
+ const registry = window.__DG__ ?? {};
158
+ window.__DG__ = registry;
159
+ const previous = registry.input?.instance ?? window[LEGACY_GLOBAL_NAMES.input];
160
+ registry.input = { version: HUB_CLIENT_VERSION, api: INPUT_SINGLETON_API, instance: system };
161
+ window[LEGACY_GLOBAL_NAMES.input] = system;
162
+ return previous !== system && isPageInputControl(previous) ? previous : null;
163
+ }
164
+
165
+ /** Whether another bundle registered an input system with a different shape number. */
166
+ function hasMismatchedPageInput(): boolean {
167
+ const registered = typeof window === 'undefined' ? undefined : window.__DG__?.input;
168
+ return registered !== undefined && registered.api !== INPUT_SINGLETON_API && isInputSystem(registered.instance);
169
+ }
170
+
171
+ let claimedInput: InputSystem | null = null;
172
+
173
+ /**
174
+ * The page-wide input system, created on first use. `options` connect it to
175
+ * the core client (its slug, the cloud save); the first call's options win.
176
+ * When another client copy with a different shape owns the page, this copy
177
+ * gets a private instance that takes the page over once its game defines
178
+ * inputs.
179
+ */
180
+ export function getInput(options: InputOptions = {}): InputSystem {
181
+ if (claimedInput !== null) {
182
+ return claimedInput;
183
+ }
184
+ const isPrivate = hasMismatchedPageInput();
185
+ claimedInput = claimSingleton({
186
+ name: 'input',
187
+ version: HUB_CLIENT_VERSION,
188
+ api: INPUT_SINGLETON_API,
189
+ create: () => createInputSystem(options, !isPrivate),
190
+ isInstance: isInputSystem,
191
+ });
192
+ return claimedInput;
193
+ }
@@ -1,77 +1,77 @@
1
- /**
2
- * The player's remaps and on-screen control customisations for one game,
3
- * persisted through the input host after every change.
4
- */
5
- import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
6
-
7
- /** A readable label of an input's binding (`w/ArrowUp · Pad 0`). */
8
- export function describeBinding(definition: InputDef | undefined): string {
9
- const parts: string[] = [];
10
- if (definition?.keys !== undefined && definition.keys.length > 0) {
11
- parts.push(definition.keys.join('/'));
12
- }
13
- if (definition?.gamepad?.button !== undefined) {
14
- parts.push(`Pad ${String(definition.gamepad.button)}`);
15
- }
16
- if (definition?.gamepad?.axis !== undefined) {
17
- parts.push(`Axis ${String(definition.gamepad.axis[0])}${definition.gamepad.axis[1]}`);
18
- }
19
- return parts.length > 0 ? parts.join(' · ') : '—';
20
- }
21
-
22
- /** The player's overrides, saved on every change. */
23
- export class PlayerRemaps {
24
- private readonly host: InputHost;
25
- private saved: InputOverrides;
26
-
27
- constructor(host: InputHost) {
28
- this.host = host;
29
- this.saved = host.loadOverrides() ?? {};
30
- }
31
-
32
- /** The overrides as they stand. */
33
- get overrides(): InputOverrides {
34
- return this.saved;
35
- }
36
-
37
- /** A declared input with the player's rebind applied. */
38
- withBinding(name: string, declared: InputDef): InputDef {
39
- return { ...declared, ...this.saved.bindings?.[name] };
40
- }
41
-
42
- /** Rebind an input. */
43
- rebind(inputName: string, patch: Partial<InputDef>): void {
44
- const bindings = this.saved.bindings ?? {};
45
- bindings[inputName] = { ...bindings[inputName], ...patch };
46
- this.saved.bindings = bindings;
47
- this.persist();
48
- }
49
-
50
- /** Move or restyle an on-screen control. */
51
- customiseControl(id: string, patch: VirtualOverride): void {
52
- const virtual = this.saved.virtual ?? {};
53
- virtual[id] = { ...virtual[id], ...patch };
54
- this.saved.virtual = virtual;
55
- this.persist();
56
- }
57
-
58
- /** Record the player's show/hide choice. */
59
- setShowVirtual(isVisible: boolean): void {
60
- this.saved.showVirtual = isVisible;
61
- this.persist();
62
- }
63
-
64
- /** Forget everything. */
65
- reset(): void {
66
- this.saved = {};
67
- this.persist();
68
- }
69
-
70
- private persist(): void {
71
- try {
72
- this.host.saveOverrides(this.saved);
73
- } catch {
74
- // Storage can be full or blocked; remaps still apply for this visit.
75
- }
76
- }
77
- }
1
+ /**
2
+ * The player's remaps and on-screen control customisations for one game,
3
+ * persisted through the input host after every change.
4
+ */
5
+ import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
6
+
7
+ /** A readable label of an input's binding (`w/ArrowUp · Pad 0`). */
8
+ export function describeBinding(definition: InputDef | undefined): string {
9
+ const parts: string[] = [];
10
+ if (definition?.keys !== undefined && definition.keys.length > 0) {
11
+ parts.push(definition.keys.join('/'));
12
+ }
13
+ if (definition?.gamepad?.button !== undefined) {
14
+ parts.push(`Pad ${String(definition.gamepad.button)}`);
15
+ }
16
+ if (definition?.gamepad?.axis !== undefined) {
17
+ parts.push(`Axis ${String(definition.gamepad.axis[0])}${definition.gamepad.axis[1]}`);
18
+ }
19
+ return parts.length > 0 ? parts.join(' · ') : '—';
20
+ }
21
+
22
+ /** The player's overrides, saved on every change. */
23
+ export class PlayerRemaps {
24
+ private readonly host: InputHost;
25
+ private saved: InputOverrides;
26
+
27
+ constructor(host: InputHost) {
28
+ this.host = host;
29
+ this.saved = host.loadOverrides() ?? {};
30
+ }
31
+
32
+ /** The overrides as they stand. */
33
+ get overrides(): InputOverrides {
34
+ return this.saved;
35
+ }
36
+
37
+ /** A declared input with the player's rebind applied. */
38
+ withBinding(name: string, declared: InputDef): InputDef {
39
+ return { ...declared, ...this.saved.bindings?.[name] };
40
+ }
41
+
42
+ /** Rebind an input. */
43
+ rebind(inputName: string, patch: Partial<InputDef>): void {
44
+ const bindings = this.saved.bindings ?? {};
45
+ bindings[inputName] = { ...bindings[inputName], ...patch };
46
+ this.saved.bindings = bindings;
47
+ this.persist();
48
+ }
49
+
50
+ /** Move or restyle an on-screen control. */
51
+ customiseControl(id: string, patch: VirtualOverride): void {
52
+ const virtual = this.saved.virtual ?? {};
53
+ virtual[id] = { ...virtual[id], ...patch };
54
+ this.saved.virtual = virtual;
55
+ this.persist();
56
+ }
57
+
58
+ /** Record the player's show/hide choice. */
59
+ setShowVirtual(isVisible: boolean): void {
60
+ this.saved.showVirtual = isVisible;
61
+ this.persist();
62
+ }
63
+
64
+ /** Forget everything. */
65
+ reset(): void {
66
+ this.saved = {};
67
+ this.persist();
68
+ }
69
+
70
+ private persist(): void {
71
+ try {
72
+ this.host.saveOverrides(this.saved);
73
+ } catch {
74
+ // Storage can be full or blocked; remaps still apply for this visit.
75
+ }
76
+ }
77
+ }