@diffenderfer-games/hub 0.0.0-stage → 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 (164) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +191 -3
  4. package/dist/auto.d.ts +36 -0
  5. package/dist/auto.d.ts.map +1 -0
  6. package/dist/auto.js +27 -0
  7. package/dist/auto.js.map +1 -0
  8. package/dist/daily.d.ts +2 -0
  9. package/dist/daily.js +1 -0
  10. package/dist/doctor/cli.mjs +268 -0
  11. package/dist/doctor/cli.mjs.map +1 -0
  12. package/dist/index.d.ts +12 -0
  13. package/dist/index.js +6 -0
  14. package/dist/input.d.ts +2 -0
  15. package/dist/input.js +3 -0
  16. package/dist/mp.d.ts +4 -0
  17. package/dist/mp.js +1 -0
  18. package/dist/race.d.ts +4 -0
  19. package/dist/race.js +1 -0
  20. package/dist/rooms.d.ts +4 -0
  21. package/dist/rooms.js +1 -0
  22. package/dist/shared/bridge.js +209 -0
  23. package/dist/shared/bridge.js.map +1 -0
  24. package/dist/shared/client-version.gen.js +7 -0
  25. package/dist/shared/client-version.gen.js.map +1 -0
  26. package/dist/shared/daily.d.ts +123 -0
  27. package/dist/shared/daily.d.ts.map +1 -0
  28. package/dist/shared/daily.js +191 -0
  29. package/dist/shared/daily.js.map +1 -0
  30. package/dist/shared/dom.js +48 -0
  31. package/dist/shared/dom.js.map +1 -0
  32. package/dist/shared/globals.js +102 -0
  33. package/dist/shared/globals.js.map +1 -0
  34. package/dist/shared/index.d.ts +706 -0
  35. package/dist/shared/index.d.ts.map +1 -0
  36. package/dist/shared/input.d.ts +730 -0
  37. package/dist/shared/input.d.ts.map +1 -0
  38. package/dist/shared/input.js +2718 -0
  39. package/dist/shared/input.js.map +1 -0
  40. package/dist/shared/latest-state.js +17 -0
  41. package/dist/shared/latest-state.js.map +1 -0
  42. package/dist/shared/mp.d.ts +9 -0
  43. package/dist/shared/mp.d.ts.map +1 -0
  44. package/dist/shared/mp.js +30 -0
  45. package/dist/shared/mp.js.map +1 -0
  46. package/dist/shared/multiplayer-types.d.ts +47 -0
  47. package/dist/shared/multiplayer-types.d.ts.map +1 -0
  48. package/dist/shared/page-stack.js +207 -0
  49. package/dist/shared/page-stack.js.map +1 -0
  50. package/dist/shared/race-types.d.ts +126 -0
  51. package/dist/shared/race-types.d.ts.map +1 -0
  52. package/dist/shared/race.d.ts +9 -0
  53. package/dist/shared/race.d.ts.map +1 -0
  54. package/dist/shared/race.js +53 -0
  55. package/dist/shared/race.js.map +1 -0
  56. package/dist/shared/room-types.d.ts +125 -0
  57. package/dist/shared/room-types.d.ts.map +1 -0
  58. package/dist/shared/rooms.d.ts +9 -0
  59. package/dist/shared/rooms.d.ts.map +1 -0
  60. package/dist/shared/rooms.js +62 -0
  61. package/dist/shared/rooms.js.map +1 -0
  62. package/dist/shared/runtime-opener.js +84 -0
  63. package/dist/shared/runtime-opener.js.map +1 -0
  64. package/dist/shared/social.d.ts +148 -0
  65. package/dist/shared/social.d.ts.map +1 -0
  66. package/dist/shared/social.js +73 -0
  67. package/dist/shared/social.js.map +1 -0
  68. package/dist/shared/src.js +2164 -0
  69. package/dist/shared/src.js.map +1 -0
  70. package/dist/shared/wire-types.d.ts +51 -0
  71. package/dist/shared/wire-types.d.ts.map +1 -0
  72. package/dist/social.d.ts +6 -0
  73. package/dist/social.js +3 -0
  74. package/dist/types.d.ts +1 -0
  75. package/dist/types.js +0 -0
  76. package/package.json +89 -6
  77. package/src/auto.ts +39 -0
  78. package/src/core/connection.ts +299 -0
  79. package/src/core/errors.ts +147 -0
  80. package/src/core/game-data.ts +38 -0
  81. package/src/core/hub-economy.ts +179 -0
  82. package/src/core/hub-progress.ts +205 -0
  83. package/src/core/hub.ts +131 -0
  84. package/src/core/menu-control.ts +61 -0
  85. package/src/core/offline/events.ts +50 -0
  86. package/src/core/offline/favorites.ts +21 -0
  87. package/src/core/offline/local-store.ts +261 -0
  88. package/src/core/offline/queue.ts +171 -0
  89. package/src/core/page.ts +74 -0
  90. package/src/core/rest/endpoint-types.ts +22 -0
  91. package/src/core/rest/rest-client.ts +152 -0
  92. package/src/core/rest/routes.gen.ts +90 -0
  93. package/src/core/storage/json-guards.ts +74 -0
  94. package/src/core/storage/key-value.ts +66 -0
  95. package/src/core/ticket.ts +43 -0
  96. package/src/core/types.ts +134 -0
  97. package/src/core/version/client-version.gen.ts +3 -0
  98. package/src/core/version/hub-version.ts +62 -0
  99. package/src/core/version/semver.ts +97 -0
  100. package/src/daily/daily-system.ts +161 -0
  101. package/src/daily/page-daily.ts +36 -0
  102. package/src/daily/types.ts +67 -0
  103. package/src/daily.ts +10 -0
  104. package/src/doctor/checks.ts +78 -0
  105. package/src/doctor/cli.ts +98 -0
  106. package/src/doctor/hub-version-fetch.ts +43 -0
  107. package/src/doctor/tsconfig.json +12 -0
  108. package/src/globals.ts +115 -0
  109. package/src/index.ts +50 -0
  110. package/src/input/device-state.ts +146 -0
  111. package/src/input/edges.ts +55 -0
  112. package/src/input/gamepad.ts +61 -0
  113. package/src/input/input-events.ts +32 -0
  114. package/src/input/input-registry.ts +164 -0
  115. package/src/input/input-system.ts +393 -0
  116. package/src/input/legacy-controls.ts +64 -0
  117. package/src/input/nav-state.ts +191 -0
  118. package/src/input/navigator.ts +171 -0
  119. package/src/input/on-screen-controls.ts +101 -0
  120. package/src/input/page-control.ts +43 -0
  121. package/src/input/page-input.ts +193 -0
  122. package/src/input/player-remaps.ts +77 -0
  123. package/src/input/source-values.ts +116 -0
  124. package/src/input/spatial.ts +47 -0
  125. package/src/input/types.ts +331 -0
  126. package/src/input/zero-config.ts +177 -0
  127. package/src/input.ts +22 -0
  128. package/src/mp.ts +10 -0
  129. package/src/overlay/controls-panel.ts +163 -0
  130. package/src/overlay/dom.ts +46 -0
  131. package/src/overlay/layout-clusters.ts +162 -0
  132. package/src/overlay/layout-geometry.ts +136 -0
  133. package/src/overlay/layout.ts +203 -0
  134. package/src/overlay/listener-scope.ts +23 -0
  135. package/src/overlay/on-screen-keyboard.ts +137 -0
  136. package/src/overlay/overlay.ts +208 -0
  137. package/src/overlay/styles.ts +54 -0
  138. package/src/overlay/virtual-controls.ts +262 -0
  139. package/src/race.ts +10 -0
  140. package/src/realtime/bridge.ts +121 -0
  141. package/src/realtime/cross-bundle.ts +29 -0
  142. package/src/realtime/fallback-notice.ts +48 -0
  143. package/src/realtime/hub-link.ts +65 -0
  144. package/src/realtime/latest-state.ts +33 -0
  145. package/src/realtime/multiplayer-forwarder.ts +28 -0
  146. package/src/realtime/multiplayer-types.ts +52 -0
  147. package/src/realtime/page-runtime.ts +60 -0
  148. package/src/realtime/race-forwarder.ts +59 -0
  149. package/src/realtime/race-types.ts +132 -0
  150. package/src/realtime/room-types.ts +125 -0
  151. package/src/realtime/rooms-forwarder.ts +79 -0
  152. package/src/realtime/runtime-api.ts +49 -0
  153. package/src/realtime/runtime-opener.ts +61 -0
  154. package/src/realtime/social-forwarder.ts +72 -0
  155. package/src/realtime/social-types.ts +78 -0
  156. package/src/realtime/types.ts +53 -0
  157. package/src/realtime/wire-types.ts +72 -0
  158. package/src/rooms.ts +10 -0
  159. package/src/social.ts +18 -0
  160. package/src/types.ts +13 -0
  161. package/src/uistack/page-stack.ts +45 -0
  162. package/src/uistack/play-suspension.ts +82 -0
  163. package/src/uistack/types.ts +76 -0
  164. package/src/uistack/ui-stack.ts +144 -0
@@ -0,0 +1,2718 @@
1
+ import { a as isObject, i as hasMethods, n as claimSingleton, o as readField, r as _defineProperty, t as LEGACY_GLOBAL_NAMES } from "./globals.js";
2
+ import { t as HUB_CLIENT_VERSION } from "./client-version.gen.js";
3
+ import { a as requireElement, i as placeAt, n as findElements, r as pageBody, t as escapeHtml } from "./dom.js";
4
+ //#region src/input/gamepad.ts
5
+ /**
6
+ * Reading standard gamepads: button indices, the stick deadzone and the
7
+ * directions a pad is pointing.
8
+ */
9
+ /** Standard-gamepad button indices. */
10
+ const PAD_BUTTON = {
11
+ A: 0,
12
+ B: 1,
13
+ X: 2,
14
+ Y: 3,
15
+ LB: 4,
16
+ RB: 5,
17
+ LT: 6,
18
+ RT: 7,
19
+ SELECT: 8,
20
+ START: 9,
21
+ UP: 12,
22
+ DOWN: 13,
23
+ LEFT: 14,
24
+ RIGHT: 15
25
+ };
26
+ /** Stick deflection below this is ignored. */
27
+ const AXIS_DEADZONE = .35;
28
+ /** The connected gamepads (the browser's list has holes). */
29
+ function readGamepads() {
30
+ if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") return [];
31
+ return navigator.getGamepads().filter((pad) => pad !== null);
32
+ }
33
+ /** Whether `pad` holds button `index`. */
34
+ function isPadButtonPressed(pad, index) {
35
+ return pad.buttons[index]?.pressed === true;
36
+ }
37
+ /** Whether any pad holds button `index`. */
38
+ function isAnyPadButtonPressed(pads, index) {
39
+ return pads.some((pad) => isPadButtonPressed(pad, index));
40
+ }
41
+ /** The directions one pad points (left stick past `deadzone`, or the d-pad). */
42
+ function padDirections(pad, deadzone) {
43
+ const horizontal = pad.axes[0] ?? 0;
44
+ const vertical = pad.axes[1] ?? 0;
45
+ return {
46
+ up: vertical < -deadzone || isPadButtonPressed(pad, PAD_BUTTON.UP),
47
+ down: vertical > deadzone || isPadButtonPressed(pad, PAD_BUTTON.DOWN),
48
+ left: horizontal < -deadzone || isPadButtonPressed(pad, PAD_BUTTON.LEFT),
49
+ right: horizontal > deadzone || isPadButtonPressed(pad, PAD_BUTTON.RIGHT)
50
+ };
51
+ }
52
+ /** The directions any pad points. */
53
+ function anyPadDirections(pads, deadzone) {
54
+ const held = {
55
+ up: false,
56
+ down: false,
57
+ left: false,
58
+ right: false
59
+ };
60
+ for (const pad of pads) {
61
+ const directions = padDirections(pad, deadzone);
62
+ held.up || (held.up = directions.up);
63
+ held.down || (held.down = directions.down);
64
+ held.left || (held.left = directions.left);
65
+ held.right || (held.right = directions.right);
66
+ }
67
+ return held;
68
+ }
69
+ //#endregion
70
+ //#region src/input/device-state.ts
71
+ /**
72
+ * The raw state of every input device, updated by DOM events and sampled once
73
+ * per frame: held keys and mouse buttons, the pointer, movement and wheel
74
+ * accumulators, one-frame pulses, on-screen sticks and buttons, and gamepads.
75
+ */
76
+ /** Keys that scroll the page; cancelled during play (not while typing). */
77
+ const SCROLL_KEYS = /* @__PURE__ */ new Set([
78
+ " ",
79
+ "arrowup",
80
+ "arrowdown",
81
+ "arrowleft",
82
+ "arrowright"
83
+ ]);
84
+ /** Input types that don't take text (buttons, checkboxes, sliders…). */
85
+ const NON_TEXT_INPUT_TYPES = /* @__PURE__ */ new Set([
86
+ "button",
87
+ "submit",
88
+ "reset",
89
+ "checkbox",
90
+ "radio",
91
+ "range",
92
+ "color",
93
+ "file",
94
+ "image"
95
+ ]);
96
+ /** Whether typing in `target` owns Space and the arrow keys. */
97
+ function isTypingTarget(target) {
98
+ if (!(target instanceof HTMLElement)) return false;
99
+ if (target.isContentEditable || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true;
100
+ return target instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(target.type);
101
+ }
102
+ /** Raw device state for one page. */
103
+ var DeviceState = class {
104
+ constructor() {
105
+ _defineProperty(this, "heldKeys", /* @__PURE__ */ new Set());
106
+ _defineProperty(this, "heldMouseButtons", /* @__PURE__ */ new Set());
107
+ _defineProperty(this, "pressedVirtualButtons", /* @__PURE__ */ new Set());
108
+ _defineProperty(this, "sticks", /* @__PURE__ */ new Map());
109
+ _defineProperty(this, "pointerX", 0);
110
+ _defineProperty(this, "pointerY", 0);
111
+ _defineProperty(this, "movement", {
112
+ dx: 0,
113
+ dy: 0
114
+ });
115
+ _defineProperty(this, "wheel", {
116
+ dx: 0,
117
+ dy: 0
118
+ });
119
+ _defineProperty(this, "gamepads", []);
120
+ _defineProperty(this, "frame", 0);
121
+ _defineProperty(this, "pulseFrames", /* @__PURE__ */ new Map());
122
+ }
123
+ /** Fire a one-frame pulse (`tap`, `mouse:dbl`). */
124
+ pulse(name) {
125
+ this.pulseFrames.set(name, this.frame);
126
+ }
127
+ /** Whether a pulse fired this frame or the last. */
128
+ isPulseActive(name) {
129
+ const firedAt = this.pulseFrames.get(name);
130
+ return firedAt === this.frame || firedAt === this.frame - 1;
131
+ }
132
+ /** Poll the gamepads. Returns whether any are connected and whether any moved. */
133
+ pollGamepads() {
134
+ this.gamepads = readGamepads();
135
+ const hasActivity = this.gamepads.some((pad) => pad.buttons.some((button) => button.pressed || button.value > .5) || pad.axes.some((axis) => Math.abs(axis) > .35));
136
+ return {
137
+ isConnected: this.gamepads.length > 0,
138
+ hasActivity
139
+ };
140
+ }
141
+ /** End-of-frame decay of the relative accumulators. */
142
+ decay() {
143
+ this.movement.dx = 0;
144
+ this.movement.dy = 0;
145
+ this.wheel.dx *= .6;
146
+ this.wheel.dy *= .6;
147
+ }
148
+ /** Release everything held (focus loss, hidden tab), so nothing sticks. */
149
+ releaseAll() {
150
+ this.heldKeys.clear();
151
+ this.heldMouseButtons.clear();
152
+ this.pressedVirtualButtons.clear();
153
+ for (const stick of this.sticks.values()) {
154
+ stick.outX = 0;
155
+ stick.outY = 0;
156
+ }
157
+ }
158
+ /** Wire the DOM listeners that feed this state. */
159
+ listen(events) {
160
+ if (typeof window === "undefined") return;
161
+ window.addEventListener("keydown", (event) => {
162
+ const key = event.key.toLowerCase();
163
+ this.heldKeys.add(key);
164
+ if (events.shouldCancelScrollKeys() && SCROLL_KEYS.has(key) && !isTypingTarget(event.target)) event.preventDefault();
165
+ });
166
+ window.addEventListener("keyup", (event) => this.heldKeys.delete(event.key.toLowerCase()));
167
+ window.addEventListener("mousemove", (event) => {
168
+ this.movement.dx += event.movementX;
169
+ this.movement.dy += event.movementY;
170
+ this.pointerX = event.clientX;
171
+ this.pointerY = event.clientY;
172
+ events.onMouseActivity();
173
+ });
174
+ window.addEventListener("mousedown", (event) => {
175
+ this.heldMouseButtons.add(event.button);
176
+ events.onMouseActivity();
177
+ });
178
+ window.addEventListener("mouseup", (event) => this.heldMouseButtons.delete(event.button));
179
+ window.addEventListener("dblclick", () => {
180
+ this.pulse("mouse:dbl");
181
+ });
182
+ window.addEventListener("wheel", (event) => {
183
+ this.wheel.dx += Math.sign(event.deltaX);
184
+ this.wheel.dy += Math.sign(event.deltaY);
185
+ }, { passive: true });
186
+ window.addEventListener("blur", () => {
187
+ this.releaseAll();
188
+ });
189
+ document.addEventListener("visibilitychange", () => {
190
+ if (document.hidden) this.releaseAll();
191
+ });
192
+ window.addEventListener("gamepadconnected", () => {
193
+ events.onGamepadConnection(true);
194
+ });
195
+ window.addEventListener("gamepaddisconnected", () => {
196
+ events.onGamepadConnection(false);
197
+ });
198
+ }
199
+ };
200
+ //#endregion
201
+ //#region src/input/edges.ts
202
+ /**
203
+ * Edge detection for polled buttons: a plain rising edge, and a rising edge
204
+ * that repeats while held (menu navigation: a first step, a pause, then steady
205
+ * steps).
206
+ */
207
+ /** Delay before a held direction starts repeating. */
208
+ const FIRST_REPEAT_DELAY_MS = 320;
209
+ /** Interval between repeats while held. */
210
+ const REPEAT_INTERVAL_MS = 130;
211
+ /** A monotonic clock in ms. */
212
+ function nowMs() {
213
+ return typeof performance === "undefined" ? Date.now() : performance.now();
214
+ }
215
+ /** Rising edges of named polled buttons. */
216
+ var RisingEdges = class {
217
+ constructor() {
218
+ _defineProperty(this, "wasPressed", /* @__PURE__ */ new Map());
219
+ }
220
+ /** Whether `key` went from released to pressed since the last call for it. */
221
+ rose(key, isPressed) {
222
+ const wasPressedBefore = this.wasPressed.get(key) === true;
223
+ this.wasPressed.set(key, isPressed);
224
+ return isPressed && !wasPressedBefore;
225
+ }
226
+ };
227
+ /** Rising edges that repeat while held. */
228
+ var RepeatingEdges = class {
229
+ constructor() {
230
+ _defineProperty(this, "timers", /* @__PURE__ */ new Map());
231
+ }
232
+ /** Whether `key` fires now: on the press, then after a delay, then steadily while held. */
233
+ fires(key, isPressed, at = nowMs()) {
234
+ let timer = this.timers.get(key);
235
+ if (timer === void 0) {
236
+ timer = {
237
+ isHeld: false,
238
+ nextAt: 0
239
+ };
240
+ this.timers.set(key, timer);
241
+ }
242
+ if (!isPressed) {
243
+ timer.isHeld = false;
244
+ return false;
245
+ }
246
+ if (!timer.isHeld) {
247
+ timer.isHeld = true;
248
+ timer.nextAt = at + FIRST_REPEAT_DELAY_MS;
249
+ return true;
250
+ }
251
+ if (at >= timer.nextAt) {
252
+ timer.nextAt = at + REPEAT_INTERVAL_MS;
253
+ return true;
254
+ }
255
+ return false;
256
+ }
257
+ };
258
+ //#endregion
259
+ //#region src/input/input-events.ts
260
+ /** Listeners by event name. */
261
+ var InputEventHub = class {
262
+ constructor() {
263
+ _defineProperty(this, "listeners", {
264
+ focus: /* @__PURE__ */ new Set(),
265
+ blur: /* @__PURE__ */ new Set(),
266
+ act: /* @__PURE__ */ new Set(),
267
+ sourcechange: /* @__PURE__ */ new Set(),
268
+ navigate: /* @__PURE__ */ new Set()
269
+ });
270
+ }
271
+ /** Subscribe. Returns the unsubscribe function. */
272
+ on(event, listener) {
273
+ const registered = this.listeners[event];
274
+ registered.add(listener);
275
+ return () => {
276
+ registered.delete(listener);
277
+ };
278
+ }
279
+ /** Deliver an event to every listener. */
280
+ emit(event, payload) {
281
+ for (const listener of [...this.listeners[event]]) try {
282
+ listener(payload);
283
+ } catch {}
284
+ }
285
+ };
286
+ //#endregion
287
+ //#region src/input/source-values.ts
288
+ /** Clamp to 0..1. */
289
+ function clampUnit(amount) {
290
+ return Math.min(1, Math.max(0, amount));
291
+ }
292
+ /** The pointer's position from screen centre on an axis, as 0..1 on one side. */
293
+ function centreValue(state, axis, side) {
294
+ const signed = ((axis === "x" ? state.pointerX / window.innerWidth : state.pointerY / window.innerHeight) - .5) * 2;
295
+ return clampUnit(side === "-" ? -signed : signed);
296
+ }
297
+ /** A signed amount read on one side. */
298
+ function onSide(amount, side) {
299
+ return side === "-" ? -amount : amount;
300
+ }
301
+ function mouseValue(state, mapping) {
302
+ let strongest = 0;
303
+ if (mapping.button !== void 0 && state.heldMouseButtons.has(mapping.button)) strongest = 1;
304
+ if (mapping.dblclick === true && state.isPulseActive("mouse:dbl")) strongest = 1;
305
+ if (mapping.centerAxis !== void 0) strongest = Math.max(strongest, centreValue(state, mapping.centerAxis, mapping.centerSide));
306
+ if (mapping.moveAxis !== void 0) {
307
+ const moved = mapping.moveAxis === "x" ? state.movement.dx : state.movement.dy;
308
+ strongest = Math.max(strongest, clampUnit(onSide(moved, mapping.moveSign) * (mapping.moveScale ?? .1)));
309
+ }
310
+ if (mapping.wheel !== void 0) strongest = Math.max(strongest, clampUnit(onSide(state.wheel.dy, mapping.wheel)));
311
+ return strongest;
312
+ }
313
+ function gamepadValue(state, mapping) {
314
+ let strongest = 0;
315
+ for (const pad of state.gamepads) {
316
+ if (mapping.button !== void 0) {
317
+ const button = pad.buttons[mapping.button];
318
+ if (button !== void 0) strongest = Math.max(strongest, button.value > 0 ? button.value : Number(button.pressed));
319
+ }
320
+ if (mapping.axis !== void 0) {
321
+ const [axisIndex, half] = mapping.axis;
322
+ const deflection = pad.axes[axisIndex] ?? 0;
323
+ const reading = half === "±" ? Math.abs(deflection) : Math.max(0, half === "+" ? deflection : -deflection);
324
+ if (Math.abs(deflection) > .35) strongest = Math.max(strongest, clampUnit(reading));
325
+ }
326
+ }
327
+ return strongest;
328
+ }
329
+ function stickValue(state, mapping) {
330
+ const stick = mapping.stick === void 0 ? void 0 : state.sticks.get(mapping.stick);
331
+ if (stick === void 0) return 0;
332
+ const readings = {
333
+ "x+": Math.max(0, stick.outX),
334
+ "x-": Math.max(0, -stick.outX),
335
+ "y+": Math.max(0, stick.outY),
336
+ "y-": Math.max(0, -stick.outY),
337
+ x: Math.abs(stick.outX),
338
+ y: Math.abs(stick.outY)
339
+ };
340
+ return mapping.axis === void 0 ? 0 : readings[mapping.axis];
341
+ }
342
+ function touchValue(state, mapping) {
343
+ let strongest = clampUnit(stickValue(state, mapping));
344
+ if (mapping.button !== void 0 && state.pressedVirtualButtons.has(mapping.button)) strongest = 1;
345
+ if (mapping.tap === true && state.isPulseActive("tap")) strongest = 1;
346
+ if (mapping.centerAxis !== void 0) strongest = Math.max(strongest, centreValue(state, mapping.centerAxis, mapping.centerSide));
347
+ return strongest;
348
+ }
349
+ /** An input's raw value now, given the active pointer. */
350
+ function readRawValue(state, definition, activeSource) {
351
+ let strongest = 0;
352
+ if (definition.keys?.some((key) => state.heldKeys.has(key.toLowerCase())) === true) strongest = 1;
353
+ if (definition.mouse !== void 0 && activeSource === "mouse") strongest = Math.max(strongest, mouseValue(state, definition.mouse));
354
+ if (definition.gamepad !== void 0 && activeSource === "gamepad") strongest = Math.max(strongest, gamepadValue(state, definition.gamepad));
355
+ if (definition.touch !== void 0) strongest = Math.max(strongest, touchValue(state, definition.touch));
356
+ return clampUnit(strongest);
357
+ }
358
+ //#endregion
359
+ //#region src/input/input-registry.ts
360
+ /** Inputs cross this to count as pressed. */
361
+ const PRESS_THRESHOLD = .5;
362
+ /** One side of an axis definition as -1..1. */
363
+ function readAxisSide(side, valueOf) {
364
+ if (side === void 0) return 0;
365
+ if (typeof side === "string") return valueOf(side);
366
+ return clampUnit(valueOf(side[1])) - clampUnit(valueOf(side[0]));
367
+ }
368
+ /** The declared inputs and their readings. */
369
+ var InputRegistry = class {
370
+ constructor() {
371
+ _defineProperty(this, "inputs", /* @__PURE__ */ new Map());
372
+ _defineProperty(this, "enabledGroups", /* @__PURE__ */ new Map());
373
+ _defineProperty(this, "axes", /* @__PURE__ */ new Map());
374
+ }
375
+ /** Replace the declared inputs. Groups keep an enabled state set before; new ones start as declared. */
376
+ declare(config, bindingOf) {
377
+ this.inputs.clear();
378
+ this.axes.clear();
379
+ for (const [group, groupDefinition] of Object.entries(config.groups)) {
380
+ if (!this.enabledGroups.has(group)) this.enabledGroups.set(group, groupDefinition.enabled ?? false);
381
+ for (const [name, definition] of Object.entries(groupDefinition.inputs)) this.inputs.set(name, {
382
+ definition: bindingOf(name, definition),
383
+ group,
384
+ raw: 0,
385
+ previous: 0
386
+ });
387
+ for (const [name, axis] of Object.entries(groupDefinition.axes ?? {})) this.axes.set(name, axis);
388
+ }
389
+ }
390
+ /** The group an input belongs to. */
391
+ groupOf(name) {
392
+ return this.inputs.get(name)?.group;
393
+ }
394
+ /** Replace one input's sources (after a rebind). */
395
+ redefine(name, definition) {
396
+ const input = this.inputs.get(name);
397
+ if (input !== void 0) input.definition = definition;
398
+ }
399
+ /** Turn a group on or off. On: held sources don't fire a phantom `down`. Off: its inputs read 0. */
400
+ setGroupEnabled(group, isEnabled, devices, source) {
401
+ this.enabledGroups.set(group, isEnabled);
402
+ for (const input of this.inputs.values()) {
403
+ if (input.group !== group) continue;
404
+ input.raw = isEnabled ? readRawValue(devices, input.definition, source) : 0;
405
+ if (isEnabled) input.previous = input.raw;
406
+ }
407
+ }
408
+ /** Whether a group is on. */
409
+ isEnabled(group) {
410
+ return this.enabledGroups.get(group) === true;
411
+ }
412
+ /** The groups that are on. */
413
+ enabledGroupNames() {
414
+ return new Set([...this.enabledGroups].filter(([, isOn]) => isOn).map(([group]) => group));
415
+ }
416
+ /** Take this frame's readings (inputs of disabled groups read 0). */
417
+ sample(devices, source) {
418
+ for (const input of this.inputs.values()) {
419
+ input.previous = input.raw;
420
+ input.raw = this.isEnabled(input.group) ? readRawValue(devices, input.definition, source) : 0;
421
+ }
422
+ }
423
+ /** Every input reads 0 this frame (the hub menu is open). */
424
+ silence() {
425
+ for (const input of this.inputs.values()) {
426
+ input.previous = input.raw;
427
+ input.raw = 0;
428
+ }
429
+ }
430
+ /** An input's value, 0..1. */
431
+ value(name) {
432
+ return this.inputs.get(name)?.raw ?? 0;
433
+ }
434
+ /** Whether an input crossed the press threshold upward this frame. */
435
+ wentDown(name) {
436
+ const input = this.inputs.get(name);
437
+ return input !== void 0 && input.previous < PRESS_THRESHOLD && input.raw >= PRESS_THRESHOLD;
438
+ }
439
+ /** Whether an input crossed the press threshold downward this frame. */
440
+ wentUp(name) {
441
+ const input = this.inputs.get(name);
442
+ return input !== void 0 && input.previous >= PRESS_THRESHOLD && input.raw < PRESS_THRESHOLD;
443
+ }
444
+ /** Whether an input is held. */
445
+ isPressed(name) {
446
+ return this.value(name) >= PRESS_THRESHOLD;
447
+ }
448
+ /** An input's full reading. */
449
+ reading(name) {
450
+ const raw = this.value(name);
451
+ return {
452
+ value: raw,
453
+ raw,
454
+ isDown: this.wentDown(name),
455
+ isUp: this.wentUp(name)
456
+ };
457
+ }
458
+ /** A named axis (or an ad-hoc negative/positive pair) as -1..1. */
459
+ axis(name, positive) {
460
+ if (positive !== void 0) return clampUnit(this.value(positive)) - clampUnit(this.value(name));
461
+ const axis = this.axes.get(name);
462
+ return axis === void 0 ? this.value(name) : readAxisSide(axis.x, (input) => this.value(input));
463
+ }
464
+ /** A named axis pair as a vector clamped to the unit circle. */
465
+ vector(name) {
466
+ const axis = this.axes.get(name);
467
+ if (axis === void 0) return {
468
+ x: 0,
469
+ y: 0,
470
+ mag: 0
471
+ };
472
+ let horizontal = readAxisSide(axis.x, (input) => this.value(input));
473
+ let vertical = readAxisSide(axis.y, (input) => this.value(input));
474
+ const magnitude = Math.hypot(horizontal, vertical);
475
+ if (magnitude > 1) {
476
+ horizontal /= magnitude;
477
+ vertical /= magnitude;
478
+ }
479
+ return {
480
+ x: horizontal,
481
+ y: vertical,
482
+ mag: Math.min(magnitude, 1)
483
+ };
484
+ }
485
+ /** An input's current sources (for the binding label). */
486
+ definitionOf(name) {
487
+ return this.inputs.get(name)?.definition;
488
+ }
489
+ };
490
+ //#endregion
491
+ //#region src/input/legacy-controls.ts
492
+ /**
493
+ * `game.controls` (`window.__HUB__.controls`): the gamepad-to-keyboard map a
494
+ * zero-config game declares. It comes from the page, so it is read field by
495
+ * field; anything malformed falls back to the defaults.
496
+ */
497
+ /** The defaults: arrows, A=Space, B=Enter, X=Z, Y=X, Select=Escape, Start=Enter. */
498
+ const DEFAULT_BUTTONS = [
499
+ [0, "Space"],
500
+ [1, "Enter"],
501
+ [2, "KeyZ"],
502
+ [3, "KeyX"],
503
+ [9, "Enter"],
504
+ [8, "Escape"]
505
+ ];
506
+ /** A text field of the map, or the default. */
507
+ function textOr(map, key, fallback) {
508
+ const field = readField(map, key);
509
+ return typeof field === "string" && field !== "" ? field : fallback;
510
+ }
511
+ /** One player's map over the defaults. */
512
+ function resolveMap(map) {
513
+ const buttons = new Map(DEFAULT_BUTTONS);
514
+ const declared = readField(map, "buttons");
515
+ if (isObject(declared)) {
516
+ for (const [index, code] of Object.entries(declared)) if (typeof code === "string" && code !== "" && /^\d+$/.test(index)) buttons.set(Number(index), code);
517
+ }
518
+ return {
519
+ up: textOr(map, "up", "ArrowUp"),
520
+ down: textOr(map, "down", "ArrowDown"),
521
+ left: textOr(map, "left", "ArrowLeft"),
522
+ right: textOr(map, "right", "ArrowRight"),
523
+ buttons
524
+ };
525
+ }
526
+ /** Parse `game.controls` (a shared map, or per-player maps in `players`). */
527
+ function parseLegacyControls(raw) {
528
+ const players = readField(raw, "players");
529
+ if (Array.isArray(players) && players.length > 0) return players.map(resolveMap);
530
+ return [resolveMap(raw)];
531
+ }
532
+ /** The map for the pad at `padIndex`. */
533
+ function controlMapFor(controls, padIndex) {
534
+ return controls[padIndex] ?? controls[0] ?? resolveMap(null);
535
+ }
536
+ //#endregion
537
+ //#region src/input/spatial.ts
538
+ /** Centres closer than this on the travel axis don't count as "in that direction". */
539
+ const MIN_TRAVEL_PX = 4;
540
+ /** The rectangle of a DOM element. */
541
+ function rectOf(element) {
542
+ const bounds = element.getBoundingClientRect();
543
+ return {
544
+ x: bounds.left,
545
+ y: bounds.top,
546
+ width: bounds.width,
547
+ height: bounds.height
548
+ };
549
+ }
550
+ /** Index of the rectangle nearest `rects[fromIndex]` strictly in `direction`, or -1. Off-axis distance counts double. */
551
+ function findNearestInDirection(rects, fromIndex, direction) {
552
+ const from = rects[fromIndex];
553
+ if (from === void 0) return -1;
554
+ const fromCentreX = from.x + from.width / 2;
555
+ const fromCentreY = from.y + from.height / 2;
556
+ const isVertical = direction === "up" || direction === "down";
557
+ let bestIndex = -1;
558
+ let bestScore = Infinity;
559
+ rects.forEach((rect, index) => {
560
+ if (index === fromIndex) return;
561
+ const dx = rect.x + rect.width / 2 - fromCentreX;
562
+ const dy = rect.y + rect.height / 2 - fromCentreY;
563
+ const travel = {
564
+ up: -dy,
565
+ down: dy,
566
+ left: -dx,
567
+ right: dx
568
+ }[direction];
569
+ if (travel <= MIN_TRAVEL_PX) return;
570
+ const score = travel + (isVertical ? Math.abs(dx) : Math.abs(dy)) * 2;
571
+ if (score < bestScore) {
572
+ bestScore = score;
573
+ bestIndex = index;
574
+ }
575
+ });
576
+ return bestIndex;
577
+ }
578
+ //#endregion
579
+ //#region src/input/nav-state.ts
580
+ const TEXT_INPUT_TYPES = /* @__PURE__ */ new Set([
581
+ "text",
582
+ "search",
583
+ "email",
584
+ "password",
585
+ "number"
586
+ ]);
587
+ /** What kind of element a DOM node is, for `act`. */
588
+ function kindOf(element) {
589
+ if (element instanceof HTMLInputElement) {
590
+ if (element.type === "range") return "range";
591
+ if (TEXT_INPUT_TYPES.has(element.type)) return "text";
592
+ }
593
+ return element instanceof HTMLTextAreaElement ? "text" : "button";
594
+ }
595
+ /** A navigable element's bounds. */
596
+ function boundsOf(element) {
597
+ if (element.getBounds !== void 0) return element.getBounds();
598
+ return element.el === void 0 ? {
599
+ x: 0,
600
+ y: 0,
601
+ width: 0,
602
+ height: 0
603
+ } : rectOf(element.el);
604
+ }
605
+ /** The text field a navigable element edits, if it is one. */
606
+ function textFieldOf(element) {
607
+ return element.el instanceof HTMLInputElement || element.el instanceof HTMLTextAreaElement ? element.el : null;
608
+ }
609
+ /** The highlight state of one navigable list. */
610
+ var NavState = class {
611
+ constructor(queryElements, shouldWrap = true) {
612
+ _defineProperty(this, "elements", []);
613
+ _defineProperty(this, "index", -1);
614
+ _defineProperty(this, "queryElements", void 0);
615
+ _defineProperty(this, "shouldWrap", void 0);
616
+ this.queryElements = queryElements;
617
+ this.shouldWrap = shouldWrap;
618
+ }
619
+ /** Re-read the list, highlighting the first element if nothing is. */
620
+ requery(highlighter, listeners) {
621
+ this.elements = this.resolve();
622
+ if (this.index >= this.elements.length) this.index = this.elements.length - 1;
623
+ if (this.index < 0 && this.elements.length > 0) {
624
+ this.index = 0;
625
+ this.focusCurrent(listeners);
626
+ }
627
+ this.position(highlighter);
628
+ }
629
+ /** Drop the highlight. */
630
+ blur(highlighter) {
631
+ this.elements[this.index]?.onBlur?.();
632
+ this.index = -1;
633
+ highlighter?.hideHighlight();
634
+ }
635
+ /** Step to the next (+1) or previous (-1) element. */
636
+ step(delta, highlighter, listeners) {
637
+ this.elements = this.resolve();
638
+ const count = this.elements.length;
639
+ if (count === 0) return;
640
+ this.blurCurrent(listeners);
641
+ let next = this.index + delta;
642
+ if (next < 0) next = this.shouldWrap ? count - 1 : 0;
643
+ if (next >= count) next = this.shouldWrap ? 0 : count - 1;
644
+ this.moveTo(next, highlighter, listeners);
645
+ }
646
+ /** Move to the nearest element in `direction`; up and down spill over to the previous and next column. */
647
+ moveInDirection(direction, highlighter, listeners) {
648
+ this.elements = this.resolve();
649
+ if (this.elements.length === 0) return;
650
+ if (this.index < 0) {
651
+ this.moveTo(0, highlighter, listeners);
652
+ return;
653
+ }
654
+ const rects = this.elements.map(boundsOf);
655
+ let target = findNearestInDirection(rects, this.index, direction);
656
+ const fallback = {
657
+ up: "left",
658
+ down: "right",
659
+ left: null,
660
+ right: null
661
+ }[direction];
662
+ if (target < 0 && fallback !== null) target = findNearestInDirection(rects, this.index, fallback);
663
+ if (target >= 0) {
664
+ this.blurCurrent(listeners);
665
+ this.moveTo(target, highlighter, listeners);
666
+ }
667
+ }
668
+ /** Activate the highlighted element: open the keyboard for text, click anything else. */
669
+ act(highlighter, listeners) {
670
+ this.elements = this.resolve();
671
+ const current = this.elements[this.index];
672
+ if (current === void 0) return;
673
+ if (current.kind === "text") highlighter?.openKeyboard(textFieldOf(current));
674
+ else current.el?.click();
675
+ current.onAct?.();
676
+ listeners.onAct({
677
+ id: current.id,
678
+ el: current.el,
679
+ kind: current.kind,
680
+ index: this.index
681
+ });
682
+ this.elements = this.resolve();
683
+ this.position(highlighter);
684
+ }
685
+ /** Move the ring to the highlighted element (or hide it). */
686
+ position(highlighter) {
687
+ if (highlighter === null) return;
688
+ const current = this.elements[this.index];
689
+ if (current === void 0) highlighter.hideHighlight();
690
+ else highlighter.showHighlight(boundsOf(current));
691
+ }
692
+ resolve() {
693
+ return this.queryElements().map((target) => target instanceof HTMLElement ? {
694
+ el: target,
695
+ kind: kindOf(target)
696
+ } : target);
697
+ }
698
+ moveTo(index, highlighter, listeners) {
699
+ this.index = index;
700
+ this.focusCurrent(listeners);
701
+ this.position(highlighter);
702
+ }
703
+ focusCurrent(listeners) {
704
+ const current = this.elements[this.index];
705
+ if (current === void 0) return;
706
+ current.el?.focus({ preventScroll: false });
707
+ current.onFocus?.();
708
+ listeners.onFocus({
709
+ id: current.id,
710
+ el: current.el,
711
+ kind: current.kind,
712
+ index: this.index
713
+ });
714
+ }
715
+ blurCurrent(listeners) {
716
+ const current = this.elements[this.index];
717
+ if (current?.onBlur !== void 0) {
718
+ current.onBlur();
719
+ listeners.onBlur({
720
+ id: current.id,
721
+ index: this.index
722
+ });
723
+ }
724
+ }
725
+ };
726
+ //#endregion
727
+ //#region src/input/navigator.ts
728
+ const DIRECTIONS = [
729
+ "up",
730
+ "down",
731
+ "left",
732
+ "right"
733
+ ];
734
+ /** The visible focusables of the hub menu. */
735
+ function menuFocusables(root) {
736
+ if (root === null) return [];
737
+ return [...root.querySelectorAll("a[href], button:not([disabled]), input, [tabindex]")].filter((element) => element.getClientRects().length > 0);
738
+ }
739
+ /** Visible elements matching a CSS selector. */
740
+ function visibleMatches(selector) {
741
+ return [...document.querySelectorAll(selector)].filter((element) => element.getClientRects().length > 0);
742
+ }
743
+ /** The menu, auto-navigate and group navigation of one input system. */
744
+ var Navigator = class {
745
+ constructor() {
746
+ _defineProperty(this, "menuState", null);
747
+ _defineProperty(this, "autoNavigateQuery", null);
748
+ _defineProperty(this, "autoNavigateState", null);
749
+ _defineProperty(this, "autoNavigateBack", null);
750
+ _defineProperty(this, "groups", /* @__PURE__ */ new Map());
751
+ }
752
+ /** Forget the declared groups (a new `define`). */
753
+ clearGroups() {
754
+ this.groups.clear();
755
+ }
756
+ /** Declare a navigable group. */
757
+ addGroup(group, definition) {
758
+ this.groups.set(group, {
759
+ definition,
760
+ state: new NavState(definition.elements, definition.wrap !== false)
761
+ });
762
+ }
763
+ /** A group was enabled: highlight its first element. */
764
+ refreshGroup(group, context) {
765
+ this.groups.get(group)?.state.requery(context.highlighter(), context.listeners);
766
+ }
767
+ /** A group was disabled: drop its highlight. */
768
+ blurGroup(group, highlighter) {
769
+ this.groups.get(group)?.state.blur(highlighter);
770
+ }
771
+ /** Opt a game's own menus into gamepad navigation. */
772
+ setAutoNavigate(query, back) {
773
+ this.autoNavigateQuery = query;
774
+ this.autoNavigateBack = back;
775
+ this.autoNavigateState = null;
776
+ }
777
+ /** Make a CSS selector's visible matches the auto-navigate targets. */
778
+ static queryFor(selector) {
779
+ return typeof selector === "string" ? () => visibleMatches(selector) : selector;
780
+ }
781
+ /** Stop auto-navigating. */
782
+ clearAutoNavigate(highlighter) {
783
+ this.autoNavigateQuery = null;
784
+ this.autoNavigateState?.blur(highlighter);
785
+ this.autoNavigateState = null;
786
+ }
787
+ /** The hub menu is open: drive it with the highlight ring (gamepad only). */
788
+ driveMenu(menu, isGamepadActive, context, highlighter) {
789
+ if (!isGamepadActive) {
790
+ highlighter?.hideHighlight();
791
+ return;
792
+ }
793
+ this.menuState ?? (this.menuState = new NavState(() => menuFocusables(menu.root)));
794
+ this.driveList(this.menuState, "m", context, () => {
795
+ menu.close();
796
+ });
797
+ }
798
+ /** The hub menu closed: drop its highlight. */
799
+ leaveMenu(highlighter) {
800
+ this.menuState?.blur(highlighter);
801
+ this.menuState = null;
802
+ }
803
+ /** A game's own menus, when opted in, visible, and a gamepad is in use. */
804
+ driveAutoNavigate(isGamepadActive, context, highlighter) {
805
+ const query = this.autoNavigateQuery;
806
+ if (query === null || !isGamepadActive || query().length === 0) {
807
+ this.autoNavigateState?.blur(highlighter);
808
+ this.autoNavigateState = null;
809
+ return;
810
+ }
811
+ this.autoNavigateState ?? (this.autoNavigateState = new NavState(query));
812
+ this.driveList(this.autoNavigateState, "an", context, this.autoNavigateBack);
813
+ }
814
+ /** The declared navigable groups that are enabled. */
815
+ driveGroups(isGroupEnabled, context) {
816
+ for (const [group, { definition, state }] of this.groups) {
817
+ if (!isGroupEnabled(group)) continue;
818
+ const highlighter = context.highlighter();
819
+ const fires = (prefix, inputName) => inputName !== void 0 && context.repeats.fires(prefix + group, context.isPressed(inputName));
820
+ const shouldStepPrevious = fires("np:", definition.prev);
821
+ const shouldStepNext = fires("nn:", definition.next);
822
+ const shouldMoveUp = fires("nu:", definition.up);
823
+ const shouldMoveDown = fires("nd:", definition.down);
824
+ if (shouldStepNext) state.step(1, highlighter, context.listeners);
825
+ else if (shouldStepPrevious) state.step(-1, highlighter, context.listeners);
826
+ else if (shouldMoveUp) state.moveInDirection("up", highlighter, context.listeners);
827
+ else if (shouldMoveDown) state.moveInDirection("down", highlighter, context.listeners);
828
+ if (definition.act !== "" && context.wentDown(definition.act)) state.act(highlighter, context.listeners);
829
+ state.position(highlighter);
830
+ }
831
+ }
832
+ /** Move with the stick or d-pad, A acts, B goes back. */
833
+ driveList(state, keyPrefix, context, back) {
834
+ const highlighter = context.highlighter();
835
+ state.requery(highlighter, context.listeners);
836
+ const held = anyPadDirections(context.gamepads, AXIS_DEADZONE);
837
+ const direction = DIRECTIONS.find((candidate) => context.repeats.fires(`${keyPrefix}:${candidate[0] ?? ""}`, held[candidate]));
838
+ if (direction !== void 0) state.moveInDirection(direction, highlighter, context.listeners);
839
+ if (context.presses.rose("act", isAnyPadButtonPressed(context.gamepads, PAD_BUTTON.A))) state.act(highlighter, context.listeners);
840
+ if (context.presses.rose("back", isAnyPadButtonPressed(context.gamepads, PAD_BUTTON.B))) back?.();
841
+ state.position(highlighter);
842
+ }
843
+ };
844
+ //#endregion
845
+ //#region src/overlay/controls-panel.ts
846
+ /**
847
+ * The Controls screen: rebind inputs, show or hide the on-screen controls,
848
+ * edit the touch layout, reset to defaults.
849
+ */
850
+ /** How long a rebind waits for a key or button. */
851
+ const REBIND_TIMEOUT_MS = 8e3;
852
+ /** The Controls screen markup for `config`. */
853
+ function panelHtml(config, host) {
854
+ let rows = "";
855
+ for (const [group, groupDefinition] of Object.entries(config.groups)) {
856
+ rows += `<h4>${escapeHtml(group)}</h4>`;
857
+ for (const name of Object.keys(groupDefinition.inputs)) {
858
+ const escapedName = escapeHtml(name);
859
+ rows += `<div class="cr"><span>${escapedName}</span><b class="bind" data-i="${escapedName}">${escapeHtml(host.bindingLabel(name))}</b><button class="rb" data-i="${escapedName}">Rebind</button></div>`;
860
+ }
861
+ }
862
+ return `
863
+ <div class="panel-card">
864
+ <h3>Controls</h3>
865
+ <label class="chk"><input type="checkbox" class="showv"> Show on-screen controls</label>
866
+ <button class="btn editlayout">Edit touch layout</button>
867
+ <div class="crs">${rows}</div>
868
+ <div class="panel-actions">
869
+ <button class="btn reset">Reset to defaults</button>
870
+ <button class="btn done">Done</button>
871
+ </div>
872
+ <p class="hint"></p>
873
+ <div class="edit-bar"><span>Drag a control to move it</span><button class="btn editdone">Done</button></div>
874
+ </div>`;
875
+ }
876
+ /** The Controls screen (one at a time). */
877
+ var ControlsPanel = class {
878
+ constructor(root, shadowHost) {
879
+ _defineProperty(this, "root", void 0);
880
+ _defineProperty(this, "shadowHost", void 0);
881
+ _defineProperty(this, "panel", null);
882
+ _defineProperty(this, "isEditingLayout", false);
883
+ this.root = root;
884
+ this.shadowHost = shadowHost;
885
+ }
886
+ /** Whether the player is dragging controls around. */
887
+ get isEditing() {
888
+ return this.isEditingLayout;
889
+ }
890
+ /** Open the screen (or close it when already open). */
891
+ toggle(config, host) {
892
+ if (config === null) return;
893
+ if (this.panel !== null) {
894
+ this.close();
895
+ return;
896
+ }
897
+ const panel = document.createElement("div");
898
+ panel.className = "panel";
899
+ panel.innerHTML = panelHtml(config, host);
900
+ this.root.appendChild(panel);
901
+ this.panel = panel;
902
+ const showToggle = requireElement(panel, ".showv", HTMLInputElement);
903
+ showToggle.checked = host.showingVirtual;
904
+ showToggle.addEventListener("change", () => {
905
+ host.showVirtual(showToggle.checked);
906
+ });
907
+ requireElement(panel, ".editlayout", HTMLButtonElement).addEventListener("click", () => {
908
+ this.setEditing(!this.isEditingLayout, host);
909
+ });
910
+ requireElement(panel, ".reset", HTMLButtonElement).addEventListener("click", () => {
911
+ host.resetControls();
912
+ this.close();
913
+ });
914
+ requireElement(panel, ".done", HTMLButtonElement).addEventListener("click", () => {
915
+ this.setEditing(false, host);
916
+ this.close();
917
+ });
918
+ requireElement(panel, ".editdone", HTMLButtonElement).addEventListener("click", () => {
919
+ this.setEditing(false, host);
920
+ });
921
+ for (const button of findElements(panel, ".rb", HTMLButtonElement)) button.addEventListener("click", () => {
922
+ this.captureRebind(button.dataset["i"] ?? "", host, panel);
923
+ });
924
+ }
925
+ close() {
926
+ this.panel?.remove();
927
+ this.panel = null;
928
+ }
929
+ setEditing(isEditing, host) {
930
+ this.isEditingLayout = isEditing;
931
+ this.shadowHost.classList.toggle("editing", isEditing);
932
+ host.setVirtualEditing(isEditing);
933
+ }
934
+ /** Wait for the next key or gamepad button and bind `inputName` to it. */
935
+ captureRebind(inputName, host, panel) {
936
+ const hint = requireElement(panel, ".hint", HTMLElement);
937
+ hint.textContent = `Press a key or gamepad button for "${inputName}"…`;
938
+ let frameRequest = 0;
939
+ const startedAt = performance.now();
940
+ const cleanUp = () => {
941
+ window.removeEventListener("keydown", onKey, true);
942
+ cancelAnimationFrame(frameRequest);
943
+ };
944
+ const finish = (patch, label) => {
945
+ host.rebind(inputName, patch);
946
+ const binding = findElements(panel, ".bind", HTMLElement).find((element) => element.dataset["i"] === inputName);
947
+ if (binding !== void 0) binding.textContent = label;
948
+ hint.textContent = "";
949
+ cleanUp();
950
+ };
951
+ function onKey(event) {
952
+ event.preventDefault();
953
+ finish({ keys: [event.key] }, event.key);
954
+ }
955
+ const pollPads = () => {
956
+ for (const pad of readGamepads()) {
957
+ const pressed = pad.buttons.findIndex((button) => button.pressed);
958
+ if (pressed >= 0) {
959
+ finish({ gamepad: { button: pressed } }, `Pad ${String(pressed)}`);
960
+ return;
961
+ }
962
+ }
963
+ if (performance.now() - startedAt > REBIND_TIMEOUT_MS) {
964
+ hint.textContent = "";
965
+ cleanUp();
966
+ return;
967
+ }
968
+ frameRequest = requestAnimationFrame(pollPads);
969
+ };
970
+ window.addEventListener("keydown", onKey, true);
971
+ frameRequest = requestAnimationFrame(pollPads);
972
+ }
973
+ };
974
+ //#endregion
975
+ //#region src/overlay/layout-clusters.ts
976
+ /** A new column starts after this many buttons. */
977
+ const MAX_COLUMN_LENGTH = 3;
978
+ /** Wrap `items` into rows no wider than `maxWidth`. */
979
+ function wrapIntoRows(items, maxWidth, gap) {
980
+ const rows = [];
981
+ let row = [];
982
+ let rowWidth = 0;
983
+ for (const item of items) {
984
+ const widened = row.length > 0 ? rowWidth + gap + item.w : item.w;
985
+ if (row.length > 0 && widened > maxWidth) {
986
+ rows.push(row);
987
+ row = [item];
988
+ rowWidth = item.w;
989
+ } else {
990
+ row.push(item);
991
+ rowWidth = widened;
992
+ }
993
+ }
994
+ if (row.length > 0) rows.push(row);
995
+ return rows;
996
+ }
997
+ /** A row's total width and its tallest item. */
998
+ function measureRow(row, gap) {
999
+ return {
1000
+ width: row.reduce((total, item) => total + item.w, 0) + gap * (row.length - 1),
1001
+ height: Math.max(...row.map((item) => item.h))
1002
+ };
1003
+ }
1004
+ /** Centred rows along the top or bottom edge (past a stick sharing the anchor). */
1005
+ function centredRows(buttons, sides, sticks, context, into) {
1006
+ const { area, gap, viewportWidth, viewportHeight } = context;
1007
+ const rows = wrapIntoRows(buttons, area.right - area.left, gap);
1008
+ const sizes = rows.map((row) => measureRow(row, gap));
1009
+ const totalHeight = sizes.reduce((total, size) => total + size.height, 0) + gap * (rows.length - 1);
1010
+ let along = sticks.span === null ? 0 : sticks.span.h + gap;
1011
+ rows.forEach((row, rowIndex) => {
1012
+ const size = sizes[rowIndex] ?? {
1013
+ width: 0,
1014
+ height: 0
1015
+ };
1016
+ let left = (viewportWidth - size.width) / 2;
1017
+ for (const item of row) {
1018
+ const nudgeX = item.offset?.x ?? 0;
1019
+ const nudgeY = item.offset?.y ?? 0;
1020
+ const centreY = sides.vertical === "top" ? area.top + along + size.height / 2 + nudgeY : sides.vertical === "bottom" ? area.bottom - along - size.height / 2 - nudgeY : (viewportHeight - totalHeight) / 2 + along + size.height / 2 + nudgeY;
1021
+ into.set(item.id, {
1022
+ x: left + nudgeX,
1023
+ y: centreY - item.h / 2,
1024
+ w: item.w,
1025
+ h: item.h
1026
+ });
1027
+ left += item.w + gap;
1028
+ }
1029
+ along += size.height + gap;
1030
+ });
1031
+ }
1032
+ /**
1033
+ * The classic layout: columns inset past any stick, stacked along the
1034
+ * vertical edge (a new inward column after three, or when too tall), or
1035
+ * centred rows for top and bottom anchors.
1036
+ */
1037
+ function clusterBesideSticks(buttons, sides, sticks, context, into) {
1038
+ if (sides.horizontal === "mid") {
1039
+ centredRows(buttons, sides, sticks, context, into);
1040
+ return;
1041
+ }
1042
+ const { area, gap, viewportHeight } = context;
1043
+ const availableHeight = area.bottom - area.top;
1044
+ let inset = sticks.span === null ? 0 : sticks.reserve;
1045
+ let along = 0;
1046
+ let columnWidth = 0;
1047
+ let countInColumn = 0;
1048
+ let column = 0;
1049
+ const cells = [];
1050
+ const columnHeights = [];
1051
+ for (const item of buttons) {
1052
+ if (countInColumn > 0 && (countInColumn >= MAX_COLUMN_LENGTH || along + item.h > availableHeight)) {
1053
+ inset += columnWidth + gap;
1054
+ along = 0;
1055
+ columnWidth = 0;
1056
+ countInColumn = 0;
1057
+ column += 1;
1058
+ }
1059
+ cells.push({
1060
+ item,
1061
+ inset,
1062
+ along,
1063
+ column
1064
+ });
1065
+ columnHeights[column] = along + item.h;
1066
+ along += item.h + gap;
1067
+ columnWidth = Math.max(columnWidth, item.w);
1068
+ countInColumn += 1;
1069
+ }
1070
+ for (const cell of cells) {
1071
+ const { item } = cell;
1072
+ const nudgeX = item.offset?.x ?? 0;
1073
+ const nudgeY = item.offset?.y ?? 0;
1074
+ const left = sides.horizontal === "left" ? area.left + cell.inset + nudgeX : area.right - cell.inset - item.w - nudgeX;
1075
+ const top = sides.vertical === "top" ? area.top + cell.along + nudgeY : sides.vertical === "bottom" ? area.bottom - cell.along - item.h - nudgeY : (viewportHeight - (columnHeights[cell.column] ?? 0)) / 2 + cell.along + nudgeY;
1076
+ into.set(item.id, {
1077
+ x: left,
1078
+ y: top,
1079
+ w: item.w,
1080
+ h: item.h
1081
+ });
1082
+ }
1083
+ }
1084
+ /**
1085
+ * The compact layout: rows just past the corner's sticks (above them for
1086
+ * bottom corners), first-declared button innermost, each row at most half
1087
+ * the screen wide.
1088
+ */
1089
+ function rowsBeyondSticks(buttons, sides, span, context) {
1090
+ const { area, gap, viewportWidth } = context;
1091
+ const placed = /* @__PURE__ */ new Map();
1092
+ const isLeft = sides.horizontal === "left";
1093
+ const maxWidth = isLeft ? viewportWidth / 2 - gap / 2 - area.left : area.right - (viewportWidth / 2 + gap / 2);
1094
+ let along = 0;
1095
+ for (const row of wrapIntoRows(buttons, maxWidth, gap)) {
1096
+ const size = measureRow(row, gap);
1097
+ const doesRowFitOverSticks = size.width <= span.w;
1098
+ let left = isLeft ? doesRowFitOverSticks ? span.x + span.w - size.width : area.left : doesRowFitOverSticks ? span.x : area.right - size.width;
1099
+ const innermostFirst = isLeft ? [...row].reverse() : row;
1100
+ for (const item of innermostFirst) {
1101
+ const nudgeX = item.offset?.x ?? 0;
1102
+ const nudgeY = item.offset?.y ?? 0;
1103
+ const centreY = sides.vertical === "bottom" ? span.y - gap - along - size.height / 2 - nudgeY : span.y + span.h + gap + along + size.height / 2 + nudgeY;
1104
+ placed.set(item.id, {
1105
+ x: left + (isLeft ? nudgeX : -nudgeX),
1106
+ y: centreY - item.h / 2,
1107
+ w: item.w,
1108
+ h: item.h
1109
+ });
1110
+ left += item.w + gap;
1111
+ }
1112
+ along += size.height + gap;
1113
+ }
1114
+ return placed;
1115
+ }
1116
+ //#endregion
1117
+ //#region src/overlay/layout-geometry.ts
1118
+ /** Buttons are this size by default; sticks and dpads are bigger. */
1119
+ const DEFAULT_BUTTON_SIZE = 72;
1120
+ const DEFAULT_STICK_SIZE = 130;
1121
+ /** Whether a control is a stick (joystick or dpad). */
1122
+ function isStickType(type) {
1123
+ return type === "joystick" || type === "dpad";
1124
+ }
1125
+ /** A control's declared size (before borders). */
1126
+ function declaredSize(definition) {
1127
+ const base = definition.size ?? (definition.type === "button" ? DEFAULT_BUTTON_SIZE : DEFAULT_STICK_SIZE);
1128
+ return {
1129
+ w: definition.width ?? base,
1130
+ h: definition.height ?? base
1131
+ };
1132
+ }
1133
+ /** The rendered size of a control: buttons and taps draw a 2px border outside their declared size; sticks draw theirs inside. */
1134
+ function controlSize(definition) {
1135
+ const declared = declaredSize(definition);
1136
+ const border = isStickType(definition.type) ? 0 : 4;
1137
+ return {
1138
+ w: declared.w + border,
1139
+ h: declared.h + border
1140
+ };
1141
+ }
1142
+ /** Which edges an anchor hugs. */
1143
+ function anchorSides(anchor) {
1144
+ return {
1145
+ horizontal: anchor.endsWith("left") ? "left" : anchor.endsWith("right") ? "right" : "mid",
1146
+ vertical: anchor.startsWith("top") ? "top" : anchor.startsWith("bottom") ? "bottom" : "mid"
1147
+ };
1148
+ }
1149
+ /** Do two boxes overlap (or come closer than `padding` px)? */
1150
+ function boxesTouch(first, second, padding = 0) {
1151
+ return first.x < second.x + second.w + padding && second.x < first.x + first.w + padding && first.y < second.y + second.h + padding && second.y < first.y + first.h + padding;
1152
+ }
1153
+ /** Clamp `amount` into `[low, high]`. */
1154
+ function clampBetween(amount, low, high) {
1155
+ return Math.max(low, Math.min(high, amount));
1156
+ }
1157
+ /** The smallest box covering both. */
1158
+ function unionBox(first, second) {
1159
+ const left = Math.min(first.x, second.x);
1160
+ const top = Math.min(first.y, second.y);
1161
+ return {
1162
+ x: left,
1163
+ y: top,
1164
+ w: Math.max(first.x + first.w, second.x + second.w) - left,
1165
+ h: Math.max(first.y + first.h, second.y + second.h) - top
1166
+ };
1167
+ }
1168
+ /** The box moved inside `area`. */
1169
+ function clampBox(box, area) {
1170
+ return {
1171
+ ...box,
1172
+ x: clampBetween(box.x, area.left, area.right - box.w),
1173
+ y: clampBetween(box.y, area.top, area.bottom - box.h)
1174
+ };
1175
+ }
1176
+ /** Whether a box lies inside `area` (half a px of slack). */
1177
+ function isInside(box, area) {
1178
+ return box.x >= area.left - .5 && box.y >= area.top - .5 && box.x + box.w <= area.right + .5 && box.y + box.h <= area.bottom + .5;
1179
+ }
1180
+ /** Grid step of the free-spot search. */
1181
+ const SEARCH_STEP = 4;
1182
+ /** The free spot inside `area` closest to `wanted`, on a 4px grid, or `null`. */
1183
+ function nearestFreeBox(wanted, area, isFree) {
1184
+ let best = null;
1185
+ let bestDistance = Infinity;
1186
+ for (let top = area.top; top <= area.bottom - wanted.h + .01; top += SEARCH_STEP) for (let left = area.left; left <= area.right - wanted.w + .01; left += SEARCH_STEP) {
1187
+ const distance = (left - wanted.x) ** 2 + (top - wanted.y) ** 2;
1188
+ if (distance >= bestDistance) continue;
1189
+ const candidate = {
1190
+ x: left,
1191
+ y: top,
1192
+ w: wanted.w,
1193
+ h: wanted.h
1194
+ };
1195
+ if (isFree(candidate)) {
1196
+ best = candidate;
1197
+ bestDistance = distance;
1198
+ }
1199
+ }
1200
+ return best;
1201
+ }
1202
+ //#endregion
1203
+ //#region src/overlay/layout.ts
1204
+ /** Controls that aren't sticks must not touch each other (px). */
1205
+ const TOUCH_PADDING = 6;
1206
+ const NO_INSETS = {
1207
+ top: 0,
1208
+ right: 0,
1209
+ bottom: 0,
1210
+ left: 0
1211
+ };
1212
+ /** The placement state of one layout run. */
1213
+ var LayoutPass = class {
1214
+ constructor(gap) {
1215
+ _defineProperty(this, "boxes", {});
1216
+ _defineProperty(this, "placed", []);
1217
+ _defineProperty(this, "stickBoxes", /* @__PURE__ */ new Set());
1218
+ _defineProperty(this, "gap", void 0);
1219
+ this.gap = gap;
1220
+ }
1221
+ commit(item, box) {
1222
+ this.boxes[item.id] = box;
1223
+ if (item.type === "tap") return;
1224
+ this.placed.push(box);
1225
+ if (isStickType(item.type)) this.stickBoxes.add(box);
1226
+ }
1227
+ /**
1228
+ * Whether `box` is clear of `others`. A stick's whole box is its touch
1229
+ * area, so everything keeps a full gap from it (a thumb on the rim must not
1230
+ * catch a button); other controls just must not touch.
1231
+ */
1232
+ isFree(box, others = this.placed) {
1233
+ return !others.some((other) => boxesTouch(box, other, this.stickBoxes.has(other) ? this.gap : TOUCH_PADDING));
1234
+ }
1235
+ };
1236
+ /**
1237
+ * Place every control for a `viewportWidth` × `viewportHeight` viewport and
1238
+ * return each control's box.
1239
+ *
1240
+ * 1. A game `pos` is honoured as given. A player-dragged control keeps its
1241
+ * spot (as % of the viewport, re-clamped inside the safe area), nudged to
1242
+ * the nearest free spot if it would land on (or within `gap` of) a stick.
1243
+ * 2. Sticks sit in their anchor's corner, side by side inward.
1244
+ * 3. Buttons cluster beside their corner's sticks, stacked in columns. If any
1245
+ * cluster doesn't fit, every corner cluster that has sticks moves into rows
1246
+ * above (below, for top corners) its sticks, first-declared button
1247
+ * innermost. Any button that still collides moves to the nearest free spot.
1248
+ */
1249
+ function layoutControls(items, viewportWidth, viewportHeight, options = {}) {
1250
+ const margin = options.margin ?? 22;
1251
+ const gap = options.gap ?? 14;
1252
+ const safe = {
1253
+ ...NO_INSETS,
1254
+ ...options.safe
1255
+ };
1256
+ const insets = {
1257
+ ...NO_INSETS,
1258
+ ...options.insets
1259
+ };
1260
+ const area = {
1261
+ left: safe.left + insets.left + margin,
1262
+ top: safe.top + insets.top + margin,
1263
+ right: viewportWidth - safe.right - insets.right - margin,
1264
+ bottom: viewportHeight - safe.bottom - insets.bottom - margin
1265
+ };
1266
+ const safeArea = {
1267
+ left: safe.left,
1268
+ top: safe.top,
1269
+ right: viewportWidth - safe.right,
1270
+ bottom: viewportHeight - safe.bottom
1271
+ };
1272
+ const context = {
1273
+ area,
1274
+ gap,
1275
+ viewportWidth,
1276
+ viewportHeight
1277
+ };
1278
+ const pass = new LayoutPass(gap);
1279
+ const autoItems = [];
1280
+ const dragged = [];
1281
+ for (const item of items) if (item.override !== void 0) dragged.push({
1282
+ item,
1283
+ wanted: {
1284
+ x: clampBetween(item.override.x / 100 * viewportWidth, safe.left, viewportWidth - safe.right - item.w),
1285
+ y: clampBetween(item.override.y / 100 * viewportHeight, safe.top, viewportHeight - safe.bottom - item.h),
1286
+ w: item.w,
1287
+ h: item.h
1288
+ }
1289
+ });
1290
+ else if (item.pos !== void 0) pass.commit(item, pinnedBox(item, item.pos, context));
1291
+ else autoItems.push(item);
1292
+ const byAnchor = /* @__PURE__ */ new Map();
1293
+ for (const item of autoItems) {
1294
+ const list = byAnchor.get(item.place) ?? [];
1295
+ list.push(item);
1296
+ byAnchor.set(item.place, list);
1297
+ }
1298
+ const sticksByAnchor = /* @__PURE__ */ new Map();
1299
+ for (const [anchor, list] of byAnchor) sticksByAnchor.set(anchor, placeSticks(list, anchor, context, pass));
1300
+ for (const { item, wanted } of dragged) {
1301
+ const isClear = item.type === "tap" || pass.isFree(wanted);
1302
+ pass.commit(item, isClear ? wanted : nearestFreeBox(wanted, safeArea, (box) => pass.isFree(box)) ?? wanted);
1303
+ }
1304
+ const classic = /* @__PURE__ */ new Map();
1305
+ for (const [anchor, list] of byAnchor) {
1306
+ const buttons = list.filter((item) => !isStickType(item.type));
1307
+ if (buttons.length > 0) clusterBesideSticks(buttons, anchorSides(anchor), sticksByAnchor.get(anchor) ?? EMPTY_SPAN, context, classic);
1308
+ }
1309
+ const classicBoxes = autoItems.filter((item) => !isStickType(item.type) && item.type !== "tap").flatMap((item) => classic.get(item.id) ?? []);
1310
+ const isCompact = classicBoxes.some((box) => !isInside(box, area) || !pass.isFree(box) || !pass.isFree(box, classicBoxes.filter((other) => other !== box)));
1311
+ const lost = [];
1312
+ for (const [anchor, list] of byAnchor) {
1313
+ const buttons = list.filter((item) => !isStickType(item.type));
1314
+ const sides = anchorSides(anchor);
1315
+ const span = sticksByAnchor.get(anchor)?.span ?? null;
1316
+ const rows = isCompact && span !== null && sides.horizontal !== "mid" && sides.vertical !== "mid" ? rowsBeyondSticks(buttons.filter((item) => item.type !== "tap"), sides, span, context) : null;
1317
+ for (const item of buttons) {
1318
+ const wanted = rows?.get(item.id) ?? classic.get(item.id);
1319
+ if (wanted === void 0) continue;
1320
+ if (item.type === "tap" || isInside(wanted, area) && pass.isFree(wanted)) pass.commit(item, wanted);
1321
+ else lost.push({
1322
+ item,
1323
+ wanted
1324
+ });
1325
+ }
1326
+ }
1327
+ for (const { item, wanted } of lost) pass.commit(item, nearestFreeBox(wanted, area, (box) => pass.isFree(box)) ?? clampBox(wanted, area));
1328
+ return pass.boxes;
1329
+ }
1330
+ const EMPTY_SPAN = {
1331
+ span: null,
1332
+ reserve: 0
1333
+ };
1334
+ /** A control pinned by `pos` (anchor sides fill in a missing axis). */
1335
+ function pinnedBox(item, pos, context) {
1336
+ const { area, viewportWidth, viewportHeight } = context;
1337
+ const sides = anchorSides(item.place);
1338
+ const anchoredLeft = sides.horizontal === "left" ? area.left : sides.horizontal === "right" ? area.right - item.w : (viewportWidth - item.w) / 2;
1339
+ const anchoredTop = sides.vertical === "top" ? area.top : sides.vertical === "bottom" ? area.bottom - item.h : (viewportHeight - item.h) / 2;
1340
+ const left = pos.left ?? (pos.right === void 0 ? anchoredLeft : viewportWidth - pos.right - item.w);
1341
+ const top = pos.top ?? (pos.bottom === void 0 ? anchoredTop : viewportHeight - pos.bottom - item.h);
1342
+ return {
1343
+ x: clampBetween(left, 0, viewportWidth - item.w),
1344
+ y: clampBetween(top, 0, viewportHeight - item.h),
1345
+ w: item.w,
1346
+ h: item.h
1347
+ };
1348
+ }
1349
+ /** Place an anchor's sticks side by side from its corner; returns their span and the width they reserve. */
1350
+ function placeSticks(list, anchor, context, pass) {
1351
+ const { area, gap, viewportWidth, viewportHeight } = context;
1352
+ const sides = anchorSides(anchor);
1353
+ let reserve = 0;
1354
+ let span = null;
1355
+ for (const item of list) {
1356
+ if (!isStickType(item.type)) continue;
1357
+ const nudgeX = item.offset?.x ?? 0;
1358
+ const nudgeY = item.offset?.y ?? 0;
1359
+ const box = {
1360
+ x: sides.horizontal === "left" ? area.left + reserve + nudgeX : sides.horizontal === "right" ? area.right - reserve - item.w - nudgeX : (viewportWidth - item.w) / 2 + nudgeX,
1361
+ y: sides.vertical === "top" ? area.top + nudgeY : sides.vertical === "bottom" ? area.bottom - item.h - nudgeY : (viewportHeight - item.h) / 2 + nudgeY,
1362
+ w: item.w,
1363
+ h: item.h
1364
+ };
1365
+ pass.commit(item, box);
1366
+ span = span === null ? { ...box } : unionBox(span, box);
1367
+ reserve += item.w + gap;
1368
+ }
1369
+ return {
1370
+ span,
1371
+ reserve
1372
+ };
1373
+ }
1374
+ //#endregion
1375
+ //#region src/overlay/listener-scope.ts
1376
+ /**
1377
+ * A group of window listeners removed together, so rebuilding the on-screen
1378
+ * controls doesn't leave window listeners behind for removed elements.
1379
+ * (Listeners on the controls themselves go away with the elements.)
1380
+ */
1381
+ var ListenerScope = class {
1382
+ constructor() {
1383
+ _defineProperty(this, "removers", []);
1384
+ }
1385
+ /** Listen on `window` until {@link ListenerScope.dispose}. */
1386
+ listenWindow(type, listener, options) {
1387
+ window.addEventListener(type, listener, options);
1388
+ this.removers.push(() => {
1389
+ window.removeEventListener(type, listener);
1390
+ });
1391
+ }
1392
+ /** Remove every listener added through this scope. */
1393
+ dispose() {
1394
+ for (const remove of this.removers.splice(0)) remove();
1395
+ }
1396
+ };
1397
+ //#endregion
1398
+ //#region src/overlay/on-screen-keyboard.ts
1399
+ /**
1400
+ * The on-screen keyboard for text fields, usable by touch, mouse or gamepad
1401
+ * (the stick or d-pad moves between keys, A presses, B or Start closes).
1402
+ */
1403
+ const LETTER_ROWS = [
1404
+ "1234567890",
1405
+ "qwertyuiop",
1406
+ "asdfghjkl",
1407
+ "zxcvbnm"
1408
+ ];
1409
+ const STICK_THRESHOLD = .5;
1410
+ /** One key's markup. */
1411
+ function keyHtml(label, key, extraClass = "") {
1412
+ return `<button class="k ${extraClass}" data-k="${key}">${label}</button>`;
1413
+ }
1414
+ const KEYBOARD_HTML = `<div class="kb-rows">${LETTER_ROWS.map((row) => `<div class="kb-row">${Array.from(row).map((character) => keyHtml(character, character)).join("")}</div>`).join("")}<div class="kb-row">${keyHtml("⇧", "shift", "wide")}${keyHtml("space", " ", "space")}${keyHtml("⌫", "back", "wide")}${keyHtml("↵", "enter", "wide")}${keyHtml("✕", "close", "wide")}</div></div>`;
1415
+ /** The direction the pads point, from the summed stick and d-pad. */
1416
+ function heldDirection(pads) {
1417
+ let horizontal = 0;
1418
+ let vertical = 0;
1419
+ for (const pad of pads) {
1420
+ horizontal += (pad.axes[0] ?? 0) - Number(pad.buttons[PAD_BUTTON.LEFT]?.pressed === true) + Number(pad.buttons[PAD_BUTTON.RIGHT]?.pressed === true);
1421
+ vertical += (pad.axes[1] ?? 0) - Number(pad.buttons[PAD_BUTTON.UP]?.pressed === true) + Number(pad.buttons[PAD_BUTTON.DOWN]?.pressed === true);
1422
+ }
1423
+ if (Math.abs(horizontal) <= STICK_THRESHOLD && Math.abs(vertical) <= STICK_THRESHOLD) return null;
1424
+ if (Math.abs(horizontal) > Math.abs(vertical)) return horizontal > 0 ? "right" : "left";
1425
+ return vertical > 0 ? "down" : "up";
1426
+ }
1427
+ /** The on-screen keyboard (one at a time, inside the overlay's shadow root). */
1428
+ var OnScreenKeyboard = class {
1429
+ constructor(root) {
1430
+ _defineProperty(this, "root", void 0);
1431
+ _defineProperty(this, "panel", null);
1432
+ _defineProperty(this, "target", null);
1433
+ _defineProperty(this, "keys", []);
1434
+ _defineProperty(this, "focusedIndex", 0);
1435
+ _defineProperty(this, "isShifted", false);
1436
+ _defineProperty(this, "repeats", new RepeatingEdges());
1437
+ _defineProperty(this, "presses", new RisingEdges());
1438
+ this.root = root;
1439
+ }
1440
+ /** Whether the keyboard is showing. */
1441
+ get isOpen() {
1442
+ return this.panel !== null;
1443
+ }
1444
+ /** Open it for `target` (replacing any open keyboard). */
1445
+ open(target) {
1446
+ this.close();
1447
+ this.target = target;
1448
+ this.isShifted = false;
1449
+ this.focusedIndex = 0;
1450
+ const panel = document.createElement("div");
1451
+ panel.className = "kb";
1452
+ panel.innerHTML = KEYBOARD_HTML;
1453
+ this.root.appendChild(panel);
1454
+ this.panel = panel;
1455
+ this.keys = findElements(panel, ".k", HTMLElement);
1456
+ for (const key of this.keys) {
1457
+ const press = (event) => {
1458
+ event.preventDefault();
1459
+ this.press(key.dataset["k"] ?? "");
1460
+ };
1461
+ key.addEventListener("mousedown", press);
1462
+ key.addEventListener("touchstart", press, { passive: false });
1463
+ }
1464
+ this.highlightFocusedKey();
1465
+ }
1466
+ /** Close it. */
1467
+ close() {
1468
+ this.panel?.remove();
1469
+ this.panel = null;
1470
+ this.target = null;
1471
+ this.keys = [];
1472
+ }
1473
+ /** Called every frame while open: gamepad navigation. */
1474
+ pollGamepads() {
1475
+ if (this.panel === null) return;
1476
+ const pads = readGamepads();
1477
+ const direction = heldDirection(pads);
1478
+ if (this.repeats.fires("move", direction !== null) && direction !== null) this.moveFocus(direction);
1479
+ if (this.presses.rose("act", isAnyPadButtonPressed(pads, PAD_BUTTON.A))) this.press(this.keys[this.focusedIndex]?.dataset["k"] ?? "");
1480
+ const isClosing = isAnyPadButtonPressed(pads, PAD_BUTTON.B) || isAnyPadButtonPressed(pads, PAD_BUTTON.START);
1481
+ if (this.presses.rose("close", isClosing)) this.close();
1482
+ }
1483
+ press(key) {
1484
+ if (key === "close" || key === "enter") {
1485
+ this.close();
1486
+ return;
1487
+ }
1488
+ if (key === "shift") {
1489
+ this.isShifted = !this.isShifted;
1490
+ return;
1491
+ }
1492
+ const target = this.target;
1493
+ if (target === null || key === "") return;
1494
+ target.value = key === "back" ? target.value.slice(0, -1) : target.value + (this.isShifted ? key.toUpperCase() : key);
1495
+ target.dispatchEvent(new Event("input", { bubbles: true }));
1496
+ }
1497
+ moveFocus(direction) {
1498
+ const next = findNearestInDirection(this.keys.map(rectOf), this.focusedIndex, direction);
1499
+ if (next >= 0) {
1500
+ this.focusedIndex = next;
1501
+ this.highlightFocusedKey();
1502
+ }
1503
+ }
1504
+ highlightFocusedKey() {
1505
+ this.keys.forEach((key, index) => key.classList.toggle("kfocus", index === this.focusedIndex));
1506
+ }
1507
+ };
1508
+ //#endregion
1509
+ //#region src/overlay/styles.ts
1510
+ /**
1511
+ * The overlay's styles (inside its shadow root, so games can't restyle it).
1512
+ */
1513
+ /** The hub accent colour. */
1514
+ const ACCENT_COLOR = "#ff5f3b";
1515
+ /** The overlay stylesheet. */
1516
+ const OVERLAY_CSS = `
1517
+ :host { all: initial; }
1518
+ .layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483000; font-family: 'Space Mono', ui-monospace, monospace; }
1519
+ .vc { position: fixed; pointer-events: auto; touch-action: none; user-select: none; -webkit-user-select: none;
1520
+ display: grid; place-items: center; color: #fff; }
1521
+ .vc-button, .vc-tap { background: var(--vc-bg, rgba(20,22,30,.55)); border: 2px solid var(--vc-color);
1522
+ font: 700 15px/1 monospace; box-shadow: 0 2px 10px rgba(0,0,0,.4); }
1523
+ .vc-button.pressed, .vc-tap.pressed { background: var(--vc-color); color: #0d0f14; }
1524
+ .vc-transparent { background: transparent; border-color: transparent; }
1525
+ .vc-joystick .base, .vc-dpad .base { position: absolute; inset: 0; background: var(--vc-bg, rgba(20,22,30,.4)); border: 2px solid var(--vc-color); }
1526
+ .vc-joystick .knob, .vc-dpad .knob { position: absolute; width: 44%; height: 44%; border-radius: 50%; background: var(--vc-color); opacity: .9; }
1527
+ :host(.editing) .vc { outline: 2px dashed #38d6ff; cursor: move; }
1528
+ /* While editing the layout the panel shrinks to a small bar at the top so the
1529
+ controls underneath can be dragged. */
1530
+ .edit-bar { display: none; }
1531
+ :host(.editing) .panel { background: transparent; pointer-events: none; place-items: start center; }
1532
+ :host(.editing) .panel-card { pointer-events: auto; width: auto; margin-top: max(10px, env(safe-area-inset-top, 0px)); padding: 8px 10px; }
1533
+ :host(.editing) .panel-card > :not(.edit-bar) { display: none; }
1534
+ :host(.editing) .edit-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
1535
+ :host(.editing) .edit-bar .btn { margin: 0; }
1536
+ .safe-probe { position: fixed; left: 0; top: 0; width: 0; height: 0; visibility: hidden; pointer-events: none;
1537
+ padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
1538
+ .ring { position: fixed; pointer-events: none; z-index: 2147483200; border: 3px solid ${ACCENT_COLOR}; border-radius: 8px;
1539
+ box-shadow: 0 0 0 2px rgba(0,0,0,.4); transition: left .08s, top .08s, width .08s, height .08s; }
1540
+ .ring[hidden] { display: none; }
1541
+ .kb { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483400; background: #0d0f14ee; padding: 10px;
1542
+ pointer-events: auto; border-top: 1px solid #2a3142; }
1543
+ .kb-rows { display: flex; flex-direction: column; gap: 6px; max-width: 760px; margin: 0 auto; }
1544
+ .kb-row { display: flex; gap: 6px; justify-content: center; }
1545
+ .kb .k { flex: 1; min-width: 0; max-width: 64px; height: 46px; border-radius: 8px; border: 1px solid #2a3142;
1546
+ background: #11141c; color: #e8e6df; font: 16px monospace; cursor: pointer; }
1547
+ .kb .k.space { max-width: 280px; } .kb .k.wide { max-width: 90px; }
1548
+ .kb .k.kfocus { border-color: ${ACCENT_COLOR}; box-shadow: 0 0 0 2px ${ACCENT_COLOR}; }
1549
+ .panel { position: fixed; inset: 0; z-index: 2147483500; display: grid; place-items: center; background: rgba(0,0,0,.6); pointer-events: auto;
1550
+ font-family: 'Space Mono', ui-monospace, monospace; }
1551
+ .panel-card { background: #0d0f14; color: #e8e6df; border: 1px solid #2a3142; border-radius: 12px; padding: 18px;
1552
+ width: min(420px, 92vw); max-height: 86vh; overflow: auto; box-shadow: 0 12px 44px rgba(0,0,0,.6); }
1553
+ .panel-card h3 { margin: 0 0 12px; } .panel-card h4 { margin: 14px 0 6px; color: #6f7787; text-transform: uppercase; font-size: 11px; letter-spacing: .08em; }
1554
+ .cr { display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 4px 0; border-bottom: 1px solid #1a1f2b; }
1555
+ .cr span { flex: 1; } .cr .bind { color: #4ade80; } .cr .rb { background: transparent; color: ${ACCENT_COLOR}; border: 1px solid #2a3142; border-radius: 6px; padding: 3px 8px; cursor: pointer; font: 11px monospace; }
1556
+ .chk { display: flex; gap: 8px; align-items: center; font-size: 13px; margin-bottom: 10px; }
1557
+ .btn { background: ${ACCENT_COLOR}; color: #0d0f14; border: 0; border-radius: 8px; padding: 8px 12px; font: 13px monospace; cursor: pointer; margin: 4px 4px 0 0; }
1558
+ .btn.editlayout, .btn.reset { background: transparent; color: #e8e6df; border: 1px solid #2a3142; }
1559
+ .panel-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
1560
+ .hint { color: #38d6ff; font-size: 12px; min-height: 1em; }
1561
+ `;
1562
+ //#endregion
1563
+ //#region src/overlay/virtual-controls.ts
1564
+ /** A dpad snaps past this deflection. */
1565
+ const DPAD_THRESHOLD = .4;
1566
+ const RADIUS_BY_SHAPE = {
1567
+ circle: "50%",
1568
+ pill: "999px",
1569
+ square: "0",
1570
+ round: "16px"
1571
+ };
1572
+ /** The first touch of a touch event. */
1573
+ function firstTouch(event) {
1574
+ return event.changedTouches[0];
1575
+ }
1576
+ function bindButton(id, element, environment) {
1577
+ const { callbacks } = environment;
1578
+ const isTapRegion = element.classList.contains("vc-transparent");
1579
+ const press = (event) => {
1580
+ if (environment.isEditing()) return;
1581
+ event.preventDefault();
1582
+ element.classList.add("pressed");
1583
+ callbacks.onButtonDown(id);
1584
+ if (isTapRegion) callbacks.onTap();
1585
+ };
1586
+ const release = (event) => {
1587
+ event.preventDefault();
1588
+ element.classList.remove("pressed");
1589
+ callbacks.onButtonUp(id);
1590
+ };
1591
+ element.addEventListener("touchstart", press, { passive: false });
1592
+ element.addEventListener("touchend", release, { passive: false });
1593
+ element.addEventListener("touchcancel", release, { passive: false });
1594
+ element.addEventListener("mousedown", press);
1595
+ environment.scope.listenWindow("mouseup", release);
1596
+ }
1597
+ function bindStick(id, wrapper, knob, definition, environment) {
1598
+ const isDpad = definition.type === "dpad";
1599
+ const axisMode = definition.axis ?? "both";
1600
+ const { callbacks } = environment;
1601
+ let activeTouchId = -1;
1602
+ let centreX = 0;
1603
+ let centreY = 0;
1604
+ let travelX = 1;
1605
+ let travelY = 1;
1606
+ const measure = () => {
1607
+ const bounds = wrapper.getBoundingClientRect();
1608
+ centreX = bounds.left + bounds.width / 2;
1609
+ centreY = bounds.top + bounds.height / 2;
1610
+ const knobRadius = knob.getBoundingClientRect().width / 2 || Math.min(bounds.width, bounds.height) * .25;
1611
+ travelX = Math.max(1, bounds.width / 2 - knobRadius);
1612
+ travelY = Math.max(1, bounds.height / 2 - knobRadius);
1613
+ };
1614
+ const moveTo = (pointerX, pointerY) => {
1615
+ let dx = axisMode === "y" ? 0 : pointerX - centreX;
1616
+ let dy = axisMode === "x" ? 0 : pointerY - centreY;
1617
+ if (axisMode === "both") {
1618
+ const limit = Math.min(travelX, travelY);
1619
+ const distance = Math.hypot(dx, dy);
1620
+ if (distance > limit) {
1621
+ dx = dx * limit / distance;
1622
+ dy = dy * limit / distance;
1623
+ }
1624
+ } else {
1625
+ dx = Math.max(-travelX, Math.min(travelX, dx));
1626
+ dy = Math.max(-travelY, Math.min(travelY, dy));
1627
+ }
1628
+ let outX = dx / travelX;
1629
+ let outY = dy / travelY;
1630
+ if (isDpad) {
1631
+ outX = Math.abs(outX) > DPAD_THRESHOLD ? Math.sign(outX) : 0;
1632
+ outY = Math.abs(outY) > DPAD_THRESHOLD ? Math.sign(outY) : 0;
1633
+ }
1634
+ knob.style.transform = `translate(${String(dx)}px, ${String(dy)}px)`;
1635
+ callbacks.onStick(id, outX, outY);
1636
+ };
1637
+ const release = () => {
1638
+ activeTouchId = -1;
1639
+ knob.style.transform = "translate(0,0)";
1640
+ callbacks.onStick(id, 0, 0);
1641
+ };
1642
+ wrapper.addEventListener("touchstart", (event) => {
1643
+ const touch = firstTouch(event);
1644
+ if (environment.isEditing() || touch === void 0) return;
1645
+ event.preventDefault();
1646
+ measure();
1647
+ activeTouchId = touch.identifier;
1648
+ moveTo(touch.clientX, touch.clientY);
1649
+ }, { passive: false });
1650
+ wrapper.addEventListener("touchmove", (event) => {
1651
+ event.preventDefault();
1652
+ for (const touch of event.changedTouches) if (touch.identifier === activeTouchId) moveTo(touch.clientX, touch.clientY);
1653
+ }, { passive: false });
1654
+ wrapper.addEventListener("touchend", release, { passive: false });
1655
+ wrapper.addEventListener("touchcancel", release, { passive: false });
1656
+ let isMouseHeld = false;
1657
+ wrapper.addEventListener("mousedown", (event) => {
1658
+ if (environment.isEditing()) return;
1659
+ measure();
1660
+ isMouseHeld = true;
1661
+ moveTo(event.clientX, event.clientY);
1662
+ });
1663
+ environment.scope.listenWindow("mousemove", (event) => {
1664
+ if (isMouseHeld) moveTo(event.clientX, event.clientY);
1665
+ });
1666
+ environment.scope.listenWindow("mouseup", () => {
1667
+ if (isMouseHeld) {
1668
+ isMouseHeld = false;
1669
+ release();
1670
+ }
1671
+ });
1672
+ }
1673
+ /** Drag-to-move while editing: the control keeps its grab point under the finger and stays fully on-screen. */
1674
+ function bindEditDrag(id, element, environment) {
1675
+ let isDragging = false;
1676
+ let grabX = 0;
1677
+ let grabY = 0;
1678
+ let droppedSpot = null;
1679
+ const start = (pointerX, pointerY) => {
1680
+ if (!environment.isEditing()) return;
1681
+ isDragging = true;
1682
+ const bounds = element.getBoundingClientRect();
1683
+ grabX = pointerX - bounds.left;
1684
+ grabY = pointerY - bounds.top;
1685
+ droppedSpot = null;
1686
+ };
1687
+ const move = (pointerX, pointerY) => {
1688
+ if (!isDragging) return;
1689
+ const viewportWidth = window.innerWidth;
1690
+ const viewportHeight = window.innerHeight;
1691
+ const left = Math.max(0, Math.min(viewportWidth - element.offsetWidth, pointerX - grabX));
1692
+ const top = Math.max(0, Math.min(viewportHeight - element.offsetHeight, pointerY - grabY));
1693
+ placeAt(element, left, top);
1694
+ droppedSpot = {
1695
+ x: left / viewportWidth * 100,
1696
+ y: top / viewportHeight * 100
1697
+ };
1698
+ };
1699
+ const end = () => {
1700
+ if (!isDragging) return;
1701
+ isDragging = false;
1702
+ if (droppedSpot !== null) environment.callbacks.onDragged(id, droppedSpot);
1703
+ };
1704
+ element.addEventListener("mousedown", (event) => {
1705
+ start(event.clientX, event.clientY);
1706
+ });
1707
+ element.addEventListener("touchstart", (event) => {
1708
+ const touch = firstTouch(event);
1709
+ if (touch !== void 0) start(touch.clientX, touch.clientY);
1710
+ }, { passive: true });
1711
+ environment.scope.listenWindow("mousemove", (event) => {
1712
+ move(event.clientX, event.clientY);
1713
+ });
1714
+ environment.scope.listenWindow("touchmove", (event) => {
1715
+ const touch = firstTouch(event);
1716
+ if (isDragging && touch !== void 0) move(touch.clientX, touch.clientY);
1717
+ });
1718
+ environment.scope.listenWindow("mouseup", end);
1719
+ environment.scope.listenWindow("touchend", end);
1720
+ }
1721
+ /** Build one on-screen control element, wired up. */
1722
+ function createVirtualControl(definition, environment) {
1723
+ const isStick = isStickType(definition.type);
1724
+ const { w: width, h: height } = declaredSize(definition);
1725
+ const wrapper = document.createElement("div");
1726
+ wrapper.className = `vc vc-${definition.type}`;
1727
+ wrapper.dataset["controlId"] = definition.id;
1728
+ Reflect.set(wrapper, "__id", definition.id);
1729
+ wrapper.style.width = `${String(width)}px`;
1730
+ wrapper.style.height = `${String(height)}px`;
1731
+ if (definition.opacity !== void 0) wrapper.style.opacity = String(definition.opacity);
1732
+ wrapper.style.setProperty("--vc-color", definition.color ?? "#ff5f3b");
1733
+ if (definition.bg !== void 0) wrapper.style.setProperty("--vc-bg", definition.bg);
1734
+ if (definition.text !== void 0) wrapper.style.color = definition.text;
1735
+ const radius = RADIUS_BY_SHAPE[definition.shape ?? (isStick ? "circle" : "round")];
1736
+ wrapper.style.borderRadius = radius;
1737
+ if (isStick) {
1738
+ wrapper.innerHTML = "<div class=\"base\"></div><div class=\"knob\"></div>";
1739
+ requireElement(wrapper, ".base", HTMLElement).style.borderRadius = radius;
1740
+ const knob = requireElement(wrapper, ".knob", HTMLElement);
1741
+ const isSingleAxis = definition.axis === "x" || definition.axis === "y";
1742
+ const knobDiameter = Math.round(Math.min(width, height) * (isSingleAxis ? .92 : .5));
1743
+ knob.style.width = `${String(knobDiameter)}px`;
1744
+ knob.style.height = `${String(knobDiameter)}px`;
1745
+ bindStick(definition.id, wrapper, knob, definition, environment);
1746
+ } else {
1747
+ wrapper.innerHTML = definition.html ?? `<span>${definition.label ?? ""}</span>`;
1748
+ if (definition.type === "tap") wrapper.classList.add("vc-transparent");
1749
+ bindButton(definition.id, wrapper, environment);
1750
+ }
1751
+ bindEditDrag(definition.id, wrapper, environment);
1752
+ return wrapper;
1753
+ }
1754
+ //#endregion
1755
+ //#region src/overlay/overlay.ts
1756
+ /** The window event the race HUD fires with the bands it covers. */
1757
+ const RACE_HUD_EVENT = "hub:race-hud";
1758
+ /** Wait for the browser to settle after a rotation before re-laying out. */
1759
+ const ORIENTATION_SETTLE_MS = 200;
1760
+ /** A number field of an event detail, or 0. */
1761
+ function numberField(detail, key) {
1762
+ const field = Number(readField(detail, key));
1763
+ return Number.isFinite(field) ? field : 0;
1764
+ }
1765
+ /** The overlay (one per input system). */
1766
+ var Overlay = class {
1767
+ constructor() {
1768
+ _defineProperty(this, "shadowHost", void 0);
1769
+ _defineProperty(this, "root", void 0);
1770
+ _defineProperty(this, "controlsLayer", void 0);
1771
+ _defineProperty(this, "ring", void 0);
1772
+ _defineProperty(this, "keyboard", void 0);
1773
+ _defineProperty(this, "controlsPanel", void 0);
1774
+ _defineProperty(this, "controls", []);
1775
+ _defineProperty(this, "controlScope", new ListenerScope());
1776
+ _defineProperty(this, "overrides", {});
1777
+ _defineProperty(this, "gameInsets", {});
1778
+ _defineProperty(this, "raceHudBands", {
1779
+ top: 0,
1780
+ bottom: 0
1781
+ });
1782
+ _defineProperty(this, "safeAreaProbe", null);
1783
+ this.shadowHost = document.createElement("div");
1784
+ this.shadowHost.id = "hub-input-root";
1785
+ this.root = this.shadowHost.attachShadow({ mode: "open" });
1786
+ this.root.innerHTML = `<style>${OVERLAY_CSS}</style>
1787
+ <div class="layer controls"></div>
1788
+ <div class="ring" hidden></div>`;
1789
+ this.controlsLayer = requireElement(this.root, ".controls", HTMLDivElement);
1790
+ this.ring = requireElement(this.root, ".ring", HTMLDivElement);
1791
+ this.keyboard = new OnScreenKeyboard(this.root);
1792
+ this.controlsPanel = new ControlsPanel(this.root, this.shadowHost);
1793
+ (pageBody() ?? document.documentElement).appendChild(this.shadowHost);
1794
+ window.addEventListener("resize", () => {
1795
+ this.layout();
1796
+ });
1797
+ window.addEventListener("orientationchange", () => {
1798
+ setTimeout(() => {
1799
+ this.layout();
1800
+ }, ORIENTATION_SETTLE_MS);
1801
+ });
1802
+ window.addEventListener(RACE_HUD_EVENT, (event) => {
1803
+ const detail = event instanceof CustomEvent ? event.detail : null;
1804
+ const isVisible = readField(detail, "visible") === true;
1805
+ const bands = {
1806
+ top: isVisible ? numberField(detail, "top") : 0,
1807
+ bottom: isVisible ? numberField(detail, "bottom") : 0
1808
+ };
1809
+ if (bands.top !== this.raceHudBands.top || bands.bottom !== this.raceHudBands.bottom) {
1810
+ this.raceHudBands = bands;
1811
+ this.layout();
1812
+ }
1813
+ });
1814
+ }
1815
+ /** (Re)build every on-screen control from the config and the player's overrides. */
1816
+ build(config, overrides, callbacks) {
1817
+ this.overrides = overrides;
1818
+ this.controlScope.dispose();
1819
+ this.controlScope = new ListenerScope();
1820
+ this.controlsLayer.innerHTML = "";
1821
+ this.controls = [];
1822
+ const environment = {
1823
+ callbacks,
1824
+ scope: this.controlScope,
1825
+ isEditing: () => this.controlsPanel.isEditing
1826
+ };
1827
+ for (const [group, groupDefinition] of Object.entries(config.groups)) for (const declared of groupDefinition.virtual ?? []) {
1828
+ const definition = {
1829
+ ...declared,
1830
+ ...overrides.virtual?.[declared.id]
1831
+ };
1832
+ const element = createVirtualControl(definition, environment);
1833
+ Reflect.set(element, "__group", group);
1834
+ this.controlsLayer.appendChild(element);
1835
+ this.controls.push({
1836
+ definition,
1837
+ group,
1838
+ element
1839
+ });
1840
+ }
1841
+ this.layout();
1842
+ }
1843
+ /** Replace a button or tap control's content at runtime. Listeners live on the wrapper, so they survive. */
1844
+ setControlHtml(id, html) {
1845
+ const control = this.controls.find((candidate) => candidate.definition.id === id);
1846
+ if (control !== void 0 && (control.definition.type === "button" || control.definition.type === "tap")) control.element.innerHTML = html;
1847
+ }
1848
+ /** Show controls of the enabled groups (or of every group, with `null`, while editing). */
1849
+ setVirtualVisible(isVisible, enabledGroups) {
1850
+ for (const control of this.controls) {
1851
+ const isShown = isVisible && (enabledGroups === null || enabledGroups.has(control.group));
1852
+ control.element.style.display = isShown ? "" : "none";
1853
+ }
1854
+ }
1855
+ /** Game insets (px) auto-placed controls keep clear of, e.g. a top HUD bar. Merged with earlier calls. */
1856
+ setInsets(insets) {
1857
+ this.gameInsets = {
1858
+ ...this.gameInsets,
1859
+ ...insets
1860
+ };
1861
+ this.layout();
1862
+ }
1863
+ /** Re-place every control for the current viewport, keeping the player's dragged spots. */
1864
+ layout() {
1865
+ if (this.controls.length === 0) return;
1866
+ const insets = {
1867
+ top: (this.gameInsets.top ?? 0) + this.raceHudBands.top,
1868
+ right: this.gameInsets.right ?? 0,
1869
+ bottom: (this.gameInsets.bottom ?? 0) + this.raceHudBands.bottom,
1870
+ left: this.gameInsets.left ?? 0
1871
+ };
1872
+ const boxes = layoutControls(this.controls.map(({ definition }) => {
1873
+ const dragged = this.overrides.virtual?.[definition.id];
1874
+ return {
1875
+ id: definition.id,
1876
+ type: definition.type,
1877
+ place: definition.place,
1878
+ ...controlSize(definition),
1879
+ pos: definition.pos,
1880
+ offset: definition.offset,
1881
+ override: dragged?.x !== void 0 && dragged.y !== void 0 ? {
1882
+ x: dragged.x,
1883
+ y: dragged.y
1884
+ } : void 0
1885
+ };
1886
+ }), window.innerWidth, window.innerHeight, {
1887
+ insets,
1888
+ safe: this.readSafeArea()
1889
+ });
1890
+ for (const control of this.controls) {
1891
+ const box = boxes[control.definition.id];
1892
+ if (box !== void 0) placeAt(control.element, box.x, box.y);
1893
+ }
1894
+ }
1895
+ /** Ring the highlighted element. */
1896
+ showHighlight(bounds) {
1897
+ this.ring.hidden = false;
1898
+ this.ring.style.left = `${String(bounds.x)}px`;
1899
+ this.ring.style.top = `${String(bounds.y)}px`;
1900
+ this.ring.style.width = `${String(bounds.width)}px`;
1901
+ this.ring.style.height = `${String(bounds.height)}px`;
1902
+ }
1903
+ /** Remove the ring. */
1904
+ hideHighlight() {
1905
+ this.ring.hidden = true;
1906
+ }
1907
+ /** Open the on-screen keyboard for a text field. */
1908
+ openKeyboard(target) {
1909
+ this.keyboard.open(target);
1910
+ }
1911
+ /** Whether the on-screen keyboard is showing. */
1912
+ get isKeyboardOpen() {
1913
+ return this.keyboard.isOpen;
1914
+ }
1915
+ /** Open (or close) the Controls screen. */
1916
+ toggleControls(config, host) {
1917
+ this.controlsPanel.toggle(config, host);
1918
+ }
1919
+ /** Per-frame work: gamepad navigation of the keyboard. */
1920
+ afterFrame() {
1921
+ this.keyboard.pollGamepads();
1922
+ }
1923
+ /** The device safe-area insets (notch, home bar), via a probe element. */
1924
+ readSafeArea() {
1925
+ if (this.safeAreaProbe === null) {
1926
+ this.safeAreaProbe = document.createElement("div");
1927
+ this.safeAreaProbe.className = "safe-probe";
1928
+ this.root.appendChild(this.safeAreaProbe);
1929
+ }
1930
+ const style = getComputedStyle(this.safeAreaProbe);
1931
+ const pixels = (value) => parseFloat(value) || 0;
1932
+ return {
1933
+ top: pixels(style.paddingTop),
1934
+ right: pixels(style.paddingRight),
1935
+ bottom: pixels(style.paddingBottom),
1936
+ left: pixels(style.paddingLeft)
1937
+ };
1938
+ }
1939
+ };
1940
+ //#endregion
1941
+ //#region src/input/on-screen-controls.ts
1942
+ /**
1943
+ * The input system's side of the overlay: creating it on demand, building
1944
+ * the on-screen controls, and deciding when they show (the player's choice,
1945
+ * else the game's default, else on touch devices without a gamepad; editing
1946
+ * the layout shows them all).
1947
+ */
1948
+ /** Whether this page counts as a touch device. */
1949
+ function detectTouchDevice() {
1950
+ return typeof navigator !== "undefined" && (navigator.maxTouchPoints > 0 || "ontouchstart" in window);
1951
+ }
1952
+ /** On-screen controls write into the device state; touching one makes touch the active source. */
1953
+ function feedDeviceState(devices, reactions) {
1954
+ return {
1955
+ onButtonDown: (id) => {
1956
+ devices.pressedVirtualButtons.add(id);
1957
+ reactions.onTouch();
1958
+ },
1959
+ onButtonUp: (id) => {
1960
+ devices.pressedVirtualButtons.delete(id);
1961
+ },
1962
+ onStick: (id, outX, outY) => {
1963
+ devices.sticks.set(id, {
1964
+ outX,
1965
+ outY
1966
+ });
1967
+ reactions.onTouch();
1968
+ },
1969
+ onTap: () => {
1970
+ devices.pulse("tap");
1971
+ reactions.onTouch();
1972
+ },
1973
+ onDragged: reactions.onDragged
1974
+ };
1975
+ }
1976
+ /** The overlay and the on-screen controls' visibility. */
1977
+ var OnScreenControls = class {
1978
+ constructor(enabledGroups) {
1979
+ _defineProperty(this, "isTouchDevice", detectTouchDevice());
1980
+ _defineProperty(
1981
+ this,
1982
+ /** Whether a gamepad is connected (controls hide by default then). */
1983
+ "isGamepadConnected",
1984
+ false
1985
+ );
1986
+ _defineProperty(
1987
+ this,
1988
+ /** The player's explicit show/hide choice, or `null`. */
1989
+ "playerChoice",
1990
+ null
1991
+ );
1992
+ _defineProperty(this, "overlayInstance", null);
1993
+ _defineProperty(this, "gameDefault", null);
1994
+ _defineProperty(this, "isEditing", false);
1995
+ _defineProperty(this, "enabledGroups", void 0);
1996
+ this.enabledGroups = enabledGroups;
1997
+ }
1998
+ /** The overlay if it exists yet. */
1999
+ get existingOverlay() {
2000
+ return this.overlayInstance;
2001
+ }
2002
+ /** The overlay, created on first use. */
2003
+ overlay() {
2004
+ this.overlayInstance ?? (this.overlayInstance = new Overlay());
2005
+ return this.overlayInstance;
2006
+ }
2007
+ /** What the "Show on-screen controls" toggle reflects (excludes the editing force). */
2008
+ get isShowing() {
2009
+ return this.playerChoice ?? this.gameDefault ?? (this.isTouchDevice && !this.isGamepadConnected);
2010
+ }
2011
+ /** Set the game's default visibility (`null` = automatic). */
2012
+ setGameDefault(isVisible) {
2013
+ this.gameDefault = isVisible;
2014
+ this.sync();
2015
+ }
2016
+ /** Force every control visible while the player edits the layout. */
2017
+ setEditing(isEditing) {
2018
+ this.isEditing = isEditing;
2019
+ this.sync();
2020
+ }
2021
+ /** (Re)build the controls from the config and overrides. */
2022
+ build(config, overrides, callbacks) {
2023
+ this.overlay().build(config, overrides, callbacks);
2024
+ this.sync();
2025
+ }
2026
+ /** Apply the visibility rules to the built controls. */
2027
+ sync() {
2028
+ if (this.overlayInstance === null) return;
2029
+ if (this.isEditing) this.overlayInstance.setVirtualVisible(true, null);
2030
+ else this.overlayInstance.setVirtualVisible(this.isShowing, this.enabledGroups());
2031
+ }
2032
+ };
2033
+ //#endregion
2034
+ //#region src/input/player-remaps.ts
2035
+ /** A readable label of an input's binding (`w/ArrowUp · Pad 0`). */
2036
+ function describeBinding(definition) {
2037
+ const parts = [];
2038
+ if (definition?.keys !== void 0 && definition.keys.length > 0) parts.push(definition.keys.join("/"));
2039
+ if (definition?.gamepad?.button !== void 0) parts.push(`Pad ${String(definition.gamepad.button)}`);
2040
+ if (definition?.gamepad?.axis !== void 0) parts.push(`Axis ${String(definition.gamepad.axis[0])}${definition.gamepad.axis[1]}`);
2041
+ return parts.length > 0 ? parts.join(" · ") : "—";
2042
+ }
2043
+ /** The player's overrides, saved on every change. */
2044
+ var PlayerRemaps = class {
2045
+ constructor(host) {
2046
+ _defineProperty(this, "host", void 0);
2047
+ _defineProperty(this, "saved", void 0);
2048
+ this.host = host;
2049
+ this.saved = host.loadOverrides() ?? {};
2050
+ }
2051
+ /** The overrides as they stand. */
2052
+ get overrides() {
2053
+ return this.saved;
2054
+ }
2055
+ /** A declared input with the player's rebind applied. */
2056
+ withBinding(name, declared) {
2057
+ return {
2058
+ ...declared,
2059
+ ...this.saved.bindings?.[name]
2060
+ };
2061
+ }
2062
+ /** Rebind an input. */
2063
+ rebind(inputName, patch) {
2064
+ const bindings = this.saved.bindings ?? {};
2065
+ bindings[inputName] = {
2066
+ ...bindings[inputName],
2067
+ ...patch
2068
+ };
2069
+ this.saved.bindings = bindings;
2070
+ this.persist();
2071
+ }
2072
+ /** Move or restyle an on-screen control. */
2073
+ customiseControl(id, patch) {
2074
+ const virtual = this.saved.virtual ?? {};
2075
+ virtual[id] = {
2076
+ ...virtual[id],
2077
+ ...patch
2078
+ };
2079
+ this.saved.virtual = virtual;
2080
+ this.persist();
2081
+ }
2082
+ /** Record the player's show/hide choice. */
2083
+ setShowVirtual(isVisible) {
2084
+ this.saved.showVirtual = isVisible;
2085
+ this.persist();
2086
+ }
2087
+ /** Forget everything. */
2088
+ reset() {
2089
+ this.saved = {};
2090
+ this.persist();
2091
+ }
2092
+ persist() {
2093
+ try {
2094
+ this.host.saveOverrides(this.saved);
2095
+ } catch {}
2096
+ }
2097
+ };
2098
+ //#endregion
2099
+ //#region src/input/zero-config.ts
2100
+ /** Synthetic keys stay pressed past half deflection. */
2101
+ const SYNTH_DEADZONE = .5;
2102
+ const FOCUS_CLASS = "gp-focus";
2103
+ const FOCUS_STYLE_ID = "gp-focus-style";
2104
+ const FOCUS_CSS = ".gp-focus{outline:3px solid #ff5f3b !important;outline-offset:2px;border-radius:4px;}";
2105
+ /** `KeyboardEvent.code` → its `key` and legacy `keyCode`. */
2106
+ const KEY_FOR_CODE = /* @__PURE__ */ new Map([
2107
+ ["ArrowUp", ["ArrowUp", 38]],
2108
+ ["ArrowDown", ["ArrowDown", 40]],
2109
+ ["ArrowLeft", ["ArrowLeft", 37]],
2110
+ ["ArrowRight", ["ArrowRight", 39]],
2111
+ ["Space", [" ", 32]],
2112
+ ["Enter", ["Enter", 13]],
2113
+ ["Escape", ["Escape", 27]],
2114
+ ["Tab", ["Tab", 9]],
2115
+ ["Backspace", ["Backspace", 8]],
2116
+ ["ShiftLeft", ["Shift", 16]],
2117
+ ["ControlLeft", ["Control", 17]],
2118
+ ["AltLeft", ["Alt", 18]]
2119
+ ]);
2120
+ for (let code = 65; code <= 90; code += 1) {
2121
+ const letter = String.fromCharCode(code);
2122
+ KEY_FOR_CODE.set(`Key${letter}`, [letter.toLowerCase(), code]);
2123
+ }
2124
+ for (let digit = 0; digit <= 9; digit += 1) KEY_FOR_CODE.set(`Digit${String(digit)}`, [String(digit), 48 + digit]);
2125
+ /** Dispatch a synthetic key event (with the legacy `keyCode`/`which` old games read) on the focused element. */
2126
+ function dispatchKey(type, code) {
2127
+ const [key, keyCode] = KEY_FOR_CODE.get(code) ?? [code, 0];
2128
+ const active = document.activeElement;
2129
+ const target = active !== null && active !== document.body ? active : document.body;
2130
+ const event = new KeyboardEvent(type, {
2131
+ key,
2132
+ code,
2133
+ bubbles: true,
2134
+ cancelable: true
2135
+ });
2136
+ Object.defineProperty(event, "keyCode", { get: () => keyCode });
2137
+ Object.defineProperty(event, "which", { get: () => keyCode });
2138
+ target.dispatchEvent(event);
2139
+ }
2140
+ /** Add the highlight style to the page once. */
2141
+ function ensureFocusStyle() {
2142
+ if (document.getElementById(FOCUS_STYLE_ID) !== null) return;
2143
+ const style = document.createElement("style");
2144
+ style.id = FOCUS_STYLE_ID;
2145
+ style.textContent = FOCUS_CSS;
2146
+ document.head.appendChild(style);
2147
+ }
2148
+ /** The keys each pad holds down, by pad index. */
2149
+ const heldCodes = /* @__PURE__ */ new Map();
2150
+ function releaseAllKeys() {
2151
+ for (const [padIndex, codes] of heldCodes) {
2152
+ for (const code of codes) dispatchKey("keyup", code);
2153
+ heldCodes.set(padIndex, /* @__PURE__ */ new Set());
2154
+ }
2155
+ }
2156
+ /** In a game: press and release the mapped keys to match each pad. */
2157
+ function synthesizeKeys(frame) {
2158
+ if (isTypingTarget(document.activeElement)) {
2159
+ releaseAllKeys();
2160
+ return;
2161
+ }
2162
+ frame.gamepads.forEach((pad, padIndex) => {
2163
+ const map = controlMapFor(frame.controls, padIndex);
2164
+ const directions = padDirections(pad, SYNTH_DEADZONE);
2165
+ const wanted = /* @__PURE__ */ new Set();
2166
+ for (const direction of [
2167
+ "up",
2168
+ "down",
2169
+ "left",
2170
+ "right"
2171
+ ]) if (directions[direction]) wanted.add(map[direction]);
2172
+ for (const [buttonIndex, code] of map.buttons) if (isPadButtonPressed(pad, buttonIndex)) wanted.add(code);
2173
+ const held = heldCodes.get(padIndex) ?? /* @__PURE__ */ new Set();
2174
+ wanted.forEach((code) => {
2175
+ if (!held.has(code)) dispatchKey("keydown", code);
2176
+ });
2177
+ held.forEach((code) => {
2178
+ if (!wanted.has(code)) dispatchKey("keyup", code);
2179
+ });
2180
+ heldCodes.set(padIndex, wanted);
2181
+ });
2182
+ }
2183
+ /** The visible game tiles on the catalog page. */
2184
+ function visibleTiles() {
2185
+ return [...document.querySelectorAll(".grid .card")].filter((card) => card.offsetParent !== null);
2186
+ }
2187
+ function highlightTile(tiles, index) {
2188
+ tiles.forEach((tile) => {
2189
+ tile.classList.remove(FOCUS_CLASS);
2190
+ });
2191
+ const tile = tiles[index];
2192
+ tile?.classList.add(FOCUS_CLASS);
2193
+ tile?.scrollIntoView({
2194
+ block: "nearest",
2195
+ inline: "nearest"
2196
+ });
2197
+ }
2198
+ /** On the catalog page: move the highlight across the tiles; A or Start opens one. */
2199
+ function navigateCatalog(frame) {
2200
+ const held = anyPadDirections(frame.gamepads, SYNTH_DEADZONE);
2201
+ const isAccepting = isAnyPadButtonPressed(frame.gamepads, PAD_BUTTON.A) || isAnyPadButtonPressed(frame.gamepads, PAD_BUTTON.START);
2202
+ const fired = {
2203
+ up: frame.navEdges.fires("lu", held.up),
2204
+ down: frame.navEdges.fires("ld", held.down),
2205
+ left: frame.navEdges.fires("ll", held.left),
2206
+ right: frame.navEdges.fires("lr", held.right)
2207
+ };
2208
+ const shouldAccept = frame.navEdges.fires("la", isAccepting) && isAccepting;
2209
+ const tiles = visibleTiles();
2210
+ if (tiles.length === 0) return;
2211
+ const focused = tiles.findIndex((tile) => tile.classList.contains(FOCUS_CLASS));
2212
+ const focusedTile = tiles[focused];
2213
+ if (focusedTile === void 0) {
2214
+ highlightTile(tiles, 0);
2215
+ return;
2216
+ }
2217
+ if (shouldAccept) {
2218
+ const href = focusedTile.getAttribute("href");
2219
+ if (href === null) focusedTile.click();
2220
+ else location.href = href;
2221
+ return;
2222
+ }
2223
+ const direction = [
2224
+ "up",
2225
+ "down",
2226
+ "left",
2227
+ "right"
2228
+ ].find((candidate) => fired[candidate]);
2229
+ if (direction === void 0) return;
2230
+ const next = findNearestInDirection(tiles.map(rectOf), focused, direction);
2231
+ if (next >= 0) highlightTile(tiles, next);
2232
+ }
2233
+ /** One frame of the zero-config fallback. */
2234
+ function runZeroConfigFrame(frame) {
2235
+ if (frame.gamepads.length === 0) return;
2236
+ ensureFocusStyle();
2237
+ if (frame.slug === null) navigateCatalog(frame);
2238
+ else synthesizeKeys(frame);
2239
+ }
2240
+ //#endregion
2241
+ //#region src/input/input-system.ts
2242
+ /** The window flag that tells every copy's zero-config fallback to stand down. */
2243
+ const INPUT_ACTIVE_FLAG = "__HUB_INPUT_ACTIVE__";
2244
+ /** The page's input system. */
2245
+ var InputSystem = class {
2246
+ constructor(host, options = {}) {
2247
+ _defineProperty(this, "host", void 0);
2248
+ _defineProperty(this, "options", void 0);
2249
+ _defineProperty(this, "config", null);
2250
+ _defineProperty(this, "remaps", void 0);
2251
+ _defineProperty(this, "registry", new InputRegistry());
2252
+ _defineProperty(this, "devices", new DeviceState());
2253
+ _defineProperty(this, "controls", new OnScreenControls(() => this.registry.enabledGroupNames()));
2254
+ _defineProperty(this, "navigator", new Navigator());
2255
+ _defineProperty(this, "repeats", new RepeatingEdges());
2256
+ _defineProperty(this, "presses", new RisingEdges());
2257
+ _defineProperty(this, "events", new InputEventHub());
2258
+ _defineProperty(this, "menu", null);
2259
+ _defineProperty(this, "legacyControls", parseLegacyControls(null));
2260
+ _defineProperty(this, "legacyControlsSource", null);
2261
+ _defineProperty(this, "activeSource", "keyboard");
2262
+ _defineProperty(this, "isPageOwner", void 0);
2263
+ _defineProperty(this, "pageOwner", null);
2264
+ this.host = host;
2265
+ this.options = options;
2266
+ this.isPageOwner = options.isPageOwner ?? true;
2267
+ this.remaps = new PlayerRemaps(host);
2268
+ this.controls.playerChoice = this.remaps.overrides.showVirtual ?? null;
2269
+ this.devices.listen({
2270
+ onMouseActivity: () => {
2271
+ this.setSource("mouse");
2272
+ },
2273
+ onGamepadConnection: (isConnected) => {
2274
+ var _this$controls;
2275
+ (_this$controls = this.controls).isGamepadConnected || (_this$controls.isGamepadConnected = isConnected);
2276
+ this.controls.sync();
2277
+ },
2278
+ shouldCancelScrollKeys: () => this.config !== null
2279
+ });
2280
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => {
2281
+ this.tick();
2282
+ });
2283
+ }
2284
+ /** Whether this page counts as a touch device. */
2285
+ get touchDevice() {
2286
+ return this.controls.isTouchDevice;
2287
+ }
2288
+ /** Declare the game's inputs (replacing any earlier declaration). The game owns input from now on. */
2289
+ define(config) {
2290
+ if (!this.isPageOwner) {
2291
+ const previous = this.options.claimPage?.(this) ?? null;
2292
+ this.isPageOwner = true;
2293
+ previous?.yieldPageTo?.(this);
2294
+ }
2295
+ this.config = config;
2296
+ this.registry.declare(config, (name, declared) => this.remaps.withBinding(name, declared));
2297
+ this.navigator.clearGroups();
2298
+ for (const [group, groupDefinition] of Object.entries(config.groups)) if (groupDefinition.navigable !== void 0) this.navigator.addGroup(group, groupDefinition.navigable);
2299
+ this.buildControls();
2300
+ Reflect.set(window, INPUT_ACTIVE_FLAG, true);
2301
+ }
2302
+ /** True once the game has declared its inputs. */
2303
+ get isDefined() {
2304
+ return this.pageOwner?.isDefined ?? this.config !== null;
2305
+ }
2306
+ /** Turn a group on. Held keys don't fire a phantom `down` (the pause key held as play resumes). */
2307
+ enable(group) {
2308
+ this.registry.setGroupEnabled(group, true, this.devices, this.activeSource);
2309
+ this.controls.sync();
2310
+ this.navigator.refreshGroup(group, this.navigatorContext());
2311
+ }
2312
+ /** Turn a group off (its inputs read 0). */
2313
+ disable(group) {
2314
+ this.registry.setGroupEnabled(group, false, this.devices, this.activeSource);
2315
+ this.controls.sync();
2316
+ this.navigator.blurGroup(group, this.controls.existingOverlay);
2317
+ }
2318
+ /** Whether a group is on. */
2319
+ isEnabled(group) {
2320
+ return this.registry.isEnabled(group);
2321
+ }
2322
+ /** An input's value, 0..1. */
2323
+ value(name) {
2324
+ return this.registry.value(name);
2325
+ }
2326
+ /** An input's full reading this frame. */
2327
+ get(name) {
2328
+ return this.registry.reading(name);
2329
+ }
2330
+ /** Whether an input went down this frame. */
2331
+ down(name) {
2332
+ return this.registry.wentDown(name);
2333
+ }
2334
+ /** Whether an input came up this frame. */
2335
+ up(name) {
2336
+ return this.registry.wentUp(name);
2337
+ }
2338
+ /** Whether an input is held (≥ 0.5). */
2339
+ pressed(name) {
2340
+ return this.registry.isPressed(name);
2341
+ }
2342
+ /** A named axis, or an ad-hoc `axis(negative, positive)` pair, as -1..1. */
2343
+ axis(name, positive) {
2344
+ return this.registry.axis(name, positive);
2345
+ }
2346
+ /** A named axis pair as a vector clamped to the unit circle. */
2347
+ vector(name) {
2348
+ return this.registry.vector(name);
2349
+ }
2350
+ /** The device the player is using right now. */
2351
+ get source() {
2352
+ return this.activeSource;
2353
+ }
2354
+ /** Subscribe to an input event. Returns the unsubscribe function. */
2355
+ on(event, listener) {
2356
+ return this.events.on(event, listener);
2357
+ }
2358
+ /** Update a button's content at runtime (e.g. an SVG reflecting game state). */
2359
+ setVirtualHtml(id, html) {
2360
+ this.controls.existingOverlay?.setControlHtml(id, html);
2361
+ }
2362
+ /** The player's show/hide choice for the on-screen controls (persisted). */
2363
+ showVirtual(isVisible) {
2364
+ this.controls.playerChoice = isVisible;
2365
+ this.remaps.setShowVirtual(isVisible);
2366
+ this.controls.sync();
2367
+ }
2368
+ /** Flip the player's show/hide choice. */
2369
+ toggleVirtual() {
2370
+ this.showVirtual(!this.showingVirtual);
2371
+ }
2372
+ /** Force the controls visible for layout editing, without changing the player's choice. */
2373
+ setVirtualEditing(isEditing) {
2374
+ this.controls.setEditing(isEditing);
2375
+ }
2376
+ /** This game's default visibility (the player's choice still wins); `null` = shown on touch devices without a pad. */
2377
+ setVirtualDefault(isVisible) {
2378
+ this.controls.setGameDefault(isVisible);
2379
+ }
2380
+ /**
2381
+ * Keep auto-placed controls clear of a band along a screen edge, e.g. your
2382
+ * top HUD: `setInsets({ top: hud.getBoundingClientRect().bottom })`. Px from
2383
+ * each edge, merged with earlier calls (0 clears one edge). Dragged controls
2384
+ * and explicit `pos` ignore it.
2385
+ */
2386
+ setInsets(insets) {
2387
+ this.controls.overlay().setInsets(insets);
2388
+ }
2389
+ /** What the "Show on-screen controls" toggle reflects. */
2390
+ get showingVirtual() {
2391
+ return this.controls.isShowing;
2392
+ }
2393
+ /** Rebind an input (persisted). */
2394
+ rebind(inputName, patch) {
2395
+ this.remaps.rebind(inputName, patch);
2396
+ const declared = this.config?.groups[this.registry.groupOf(inputName) ?? ""]?.inputs[inputName];
2397
+ if (declared !== void 0) this.registry.redefine(inputName, this.remaps.withBinding(inputName, declared));
2398
+ }
2399
+ /** Move or restyle an on-screen control (persisted). */
2400
+ setVirtual(id, patch) {
2401
+ this.remaps.customiseControl(id, patch);
2402
+ this.buildControls();
2403
+ }
2404
+ /** Open (or close) the Controls screen. */
2405
+ openControls() {
2406
+ if (this.pageOwner === null) this.controls.overlay().toggleControls(this.config, this);
2407
+ else this.pageOwner.openControls();
2408
+ }
2409
+ /** Forget every remap and customisation. */
2410
+ resetControls() {
2411
+ this.remaps.reset();
2412
+ this.controls.playerChoice = null;
2413
+ if (this.config !== null) this.define(this.config);
2414
+ }
2415
+ /** A readable label of an input's current binding. */
2416
+ bindingLabel(inputName) {
2417
+ return describeBinding(this.registry.definitionOf(inputName));
2418
+ }
2419
+ /** Called by the injected menu: Select toggles it and a gamepad navigates it. */
2420
+ attachMenu(bridge) {
2421
+ if (this.pageOwner === null) this.menu = bridge;
2422
+ else this.pageOwner.attachMenu(bridge);
2423
+ }
2424
+ /** Called by the injected menu with `game.controls`, for the zero-config fallback. */
2425
+ setLegacyControls(controls) {
2426
+ if (this.pageOwner === null) {
2427
+ this.legacyControlsSource = controls;
2428
+ this.legacyControls = parseLegacyControls(controls);
2429
+ } else this.pageOwner.setLegacyControls(controls);
2430
+ }
2431
+ /**
2432
+ * Make a game's own on-screen menu gamepad-navigable: a CSS selector (or a
2433
+ * function returning elements). While any match is visible and a gamepad is
2434
+ * in use, the stick moves a highlight, A clicks, B calls `options.back`.
2435
+ * Re-queried every frame, so call it once.
2436
+ */
2437
+ autoNavigate(selector, options) {
2438
+ this.navigator.setAutoNavigate(Navigator.queryFor(selector), options?.back ?? null);
2439
+ }
2440
+ /** Stop navigating the game's own menu. */
2441
+ clearAutoNavigate() {
2442
+ this.navigator.clearAutoNavigate(this.controls.existingOverlay);
2443
+ }
2444
+ /**
2445
+ * Stop driving the page because another copy's game owns it (plan §4.4):
2446
+ * hand the menu and `game.controls` to `owner`, and forward the menu's calls to it.
2447
+ */
2448
+ yieldPageTo(owner) {
2449
+ this.isPageOwner = false;
2450
+ this.pageOwner = owner;
2451
+ owner.attachMenu(this.menu);
2452
+ owner.setLegacyControls(this.legacyControlsSource);
2453
+ this.menu = null;
2454
+ }
2455
+ buildControls() {
2456
+ if (this.config === null) return;
2457
+ const callbacks = feedDeviceState(this.devices, {
2458
+ onTouch: () => {
2459
+ this.setSource("touch");
2460
+ },
2461
+ onDragged: (id, spot) => {
2462
+ this.setVirtual(id, spot);
2463
+ }
2464
+ });
2465
+ this.controls.build(this.config, this.remaps.overrides, callbacks);
2466
+ }
2467
+ setSource(source) {
2468
+ if (source === this.activeSource) return;
2469
+ this.activeSource = source;
2470
+ this.events.emit("sourcechange", source);
2471
+ this.controls.sync();
2472
+ }
2473
+ navigatorContext() {
2474
+ return {
2475
+ gamepads: this.devices.gamepads,
2476
+ repeats: this.repeats,
2477
+ presses: this.presses,
2478
+ listeners: {
2479
+ onFocus: (event) => {
2480
+ this.events.emit("focus", event);
2481
+ },
2482
+ onBlur: (event) => {
2483
+ this.events.emit("blur", event);
2484
+ },
2485
+ onAct: (event) => {
2486
+ this.events.emit("act", event);
2487
+ }
2488
+ },
2489
+ highlighter: () => this.controls.overlay(),
2490
+ isPressed: (name) => this.registry.isPressed(name),
2491
+ wentDown: (name) => this.registry.wentDown(name)
2492
+ };
2493
+ }
2494
+ tick() {
2495
+ this.devices.frame += 1;
2496
+ const { isConnected, hasActivity } = this.devices.pollGamepads();
2497
+ this.controls.isGamepadConnected = isConnected;
2498
+ if (hasActivity) this.setSource("gamepad");
2499
+ if (this.isPageOwner) this.driveFrame();
2500
+ else this.registry.sample(this.devices, this.activeSource);
2501
+ this.devices.decay();
2502
+ this.controls.existingOverlay?.afterFrame();
2503
+ requestAnimationFrame(() => {
2504
+ this.tick();
2505
+ });
2506
+ }
2507
+ /** One frame as the page owner: the menu toggle and navigation, inputs, the zero-config fallback. */
2508
+ driveFrame() {
2509
+ const context = this.navigatorContext();
2510
+ const overlay = this.controls.existingOverlay;
2511
+ const isGamepadActive = this.activeSource === "gamepad";
2512
+ const menu = this.menu;
2513
+ if (menu !== null && this.presses.rose("select", isAnyPadButtonPressed(this.devices.gamepads, PAD_BUTTON.SELECT))) menu.toggle();
2514
+ if (menu?.isOpen() === true) {
2515
+ this.registry.silence();
2516
+ this.navigator.driveMenu(menu, isGamepadActive, context, overlay);
2517
+ return;
2518
+ }
2519
+ this.navigator.leaveMenu(overlay);
2520
+ if (this.config !== null) {
2521
+ this.registry.sample(this.devices, this.activeSource);
2522
+ this.navigator.driveGroups((group) => this.registry.isEnabled(group), context);
2523
+ } else if (Reflect.get(window, INPUT_ACTIVE_FLAG) !== true) runZeroConfigFrame({
2524
+ gamepads: this.devices.gamepads,
2525
+ slug: this.host.slug,
2526
+ controls: this.legacyControls,
2527
+ navEdges: this.repeats
2528
+ });
2529
+ this.navigator.driveAutoNavigate(isGamepadActive, context, overlay);
2530
+ }
2531
+ };
2532
+ //#endregion
2533
+ //#region src/input/page-control.ts
2534
+ /**
2535
+ * The small, stable slice of the input system that other bundles on the page
2536
+ * use (plan §4.4): the host menu (attach itself, `game.controls`, the
2537
+ * Controls button), the social runtime's dialogs (navigate them), the
2538
+ * overlay stack (suspend `play`), and a handover between two client copies.
2539
+ * Every client version keeps these methods.
2540
+ */
2541
+ /** The methods every input system on the page has, v1 copies included. */
2542
+ const PAGE_INPUT_METHODS = [
2543
+ "attachMenu",
2544
+ "setLegacyControls",
2545
+ "openControls",
2546
+ "isEnabled",
2547
+ "enable",
2548
+ "disable",
2549
+ "autoNavigate",
2550
+ "clearAutoNavigate",
2551
+ "define",
2552
+ "value"
2553
+ ];
2554
+ /** Whether `candidate` is some bundle's input system. */
2555
+ function isPageInputControl(candidate) {
2556
+ return hasMethods(candidate, PAGE_INPUT_METHODS);
2557
+ }
2558
+ //#endregion
2559
+ //#region src/input/page-input.ts
2560
+ /**
2561
+ * The page's one input system (plan §4.4). The game's copy of the client owns
2562
+ * the page because the game defines the controls; the host menu reaches it
2563
+ * through the stable {@link PageInputControl} slice. Remaps persist to
2564
+ * localStorage (offline-safe) and, through the core client, to a reserved
2565
+ * cloud save slot.
2566
+ */
2567
+ /** The cross-bundle shape number of the `input` singleton. Bump only on an incompatible change. */
2568
+ const INPUT_SINGLETON_API = 1;
2569
+ /** The public methods of every input system, v1 copies included. */
2570
+ const INPUT_SYSTEM_METHODS = [
2571
+ "define",
2572
+ "enable",
2573
+ "disable",
2574
+ "isEnabled",
2575
+ "value",
2576
+ "get",
2577
+ "down",
2578
+ "up",
2579
+ "pressed",
2580
+ "axis",
2581
+ "vector",
2582
+ "on",
2583
+ "setVirtualHtml",
2584
+ "showVirtual",
2585
+ "setInsets",
2586
+ "autoNavigate",
2587
+ "clearAutoNavigate",
2588
+ "attachMenu",
2589
+ "setLegacyControls",
2590
+ "openControls",
2591
+ "rebind",
2592
+ "setVirtual"
2593
+ ];
2594
+ /** Whether `candidate` is some bundle's input system (v1 copies included). */
2595
+ function isInputSystem(candidate) {
2596
+ return hasMethods(candidate, INPUT_SYSTEM_METHODS);
2597
+ }
2598
+ /** A finite number, or `undefined`. */
2599
+ function finiteNumber(candidate) {
2600
+ return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : void 0;
2601
+ }
2602
+ /** A saved rebind: the keys or gamepad button the player chose. */
2603
+ function parseBinding(saved) {
2604
+ const binding = {};
2605
+ const keys = readField(saved, "keys");
2606
+ if (Array.isArray(keys) && keys.every((key) => typeof key === "string")) binding.keys = keys.filter((key) => typeof key === "string");
2607
+ const button = finiteNumber(readField(readField(saved, "gamepad"), "button"));
2608
+ if (button !== void 0) binding.gamepad = { button };
2609
+ return binding;
2610
+ }
2611
+ /** A saved dragged spot. */
2612
+ function parseVirtualOverride(saved) {
2613
+ const spot = {};
2614
+ const left = finiteNumber(readField(saved, "x"));
2615
+ const top = finiteNumber(readField(saved, "y"));
2616
+ if (left !== void 0) spot.x = left;
2617
+ if (top !== void 0) spot.y = top;
2618
+ return spot;
2619
+ }
2620
+ /** Entries of a saved record, parsed. */
2621
+ function parseRecord(saved, parseEntry) {
2622
+ if (!isObject(saved)) return;
2623
+ return Object.fromEntries(Object.entries(saved).map(([key, entry]) => [key, parseEntry(entry)]));
2624
+ }
2625
+ /** Saved overrides from JSON, keeping only well-formed fields. */
2626
+ function parseInputOverrides(saved) {
2627
+ const overrides = {};
2628
+ const bindings = parseRecord(readField(saved, "bindings"), parseBinding);
2629
+ const virtual = parseRecord(readField(saved, "virtual"), parseVirtualOverride);
2630
+ const showVirtual = readField(saved, "showVirtual");
2631
+ if (bindings !== void 0) overrides.bindings = bindings;
2632
+ if (virtual !== void 0) overrides.virtual = virtual;
2633
+ if (typeof showVirtual === "boolean") overrides.showVirtual = showVirtual;
2634
+ return overrides;
2635
+ }
2636
+ /** A field of the host's injected `window.__HUB__`. */
2637
+ function injectedField(key) {
2638
+ return typeof window === "undefined" ? void 0 : readField(readField(window, "__HUB__"), key);
2639
+ }
2640
+ /** The slug the host injected, or `null`. */
2641
+ function injectedSlug() {
2642
+ const slug = injectedField("slug");
2643
+ return typeof slug === "string" ? slug : null;
2644
+ }
2645
+ /** A new input system, starting from the host's injected `game.controls` (the menu may pass them again later). */
2646
+ function createInputSystem(options, isPageOwner) {
2647
+ const system = new InputSystem(createInputHost(options), {
2648
+ isPageOwner,
2649
+ claimPage: registerAsPageInput
2650
+ });
2651
+ system.setLegacyControls(injectedField("controls") ?? null);
2652
+ return system;
2653
+ }
2654
+ /** Persistence for remaps: localStorage under `hub:<slug>:input`, mirrored to the cloud when given. */
2655
+ function createInputHost(options) {
2656
+ const slug = options.slug === void 0 ? injectedSlug() : options.slug;
2657
+ const storageKey = `hub:${slug ?? "null"}:input`;
2658
+ return {
2659
+ slug,
2660
+ loadOverrides() {
2661
+ try {
2662
+ const saved = localStorage.getItem(storageKey);
2663
+ if (saved === null) return null;
2664
+ return parseInputOverrides(JSON.parse(saved));
2665
+ } catch {
2666
+ return null;
2667
+ }
2668
+ },
2669
+ saveOverrides(overrides) {
2670
+ try {
2671
+ localStorage.setItem(storageKey, JSON.stringify(overrides));
2672
+ } catch {}
2673
+ if (slug !== null) options.saveToCloud?.(overrides);
2674
+ }
2675
+ };
2676
+ }
2677
+ /** Make `system` the page's registered input system; returns the one it replaces. */
2678
+ function registerAsPageInput(system) {
2679
+ const registry = window.__DG__ ?? {};
2680
+ window.__DG__ = registry;
2681
+ const previous = registry.input?.instance ?? window[LEGACY_GLOBAL_NAMES.input];
2682
+ registry.input = {
2683
+ version: HUB_CLIENT_VERSION,
2684
+ api: 1,
2685
+ instance: system
2686
+ };
2687
+ window[LEGACY_GLOBAL_NAMES.input] = system;
2688
+ return previous !== system && isPageInputControl(previous) ? previous : null;
2689
+ }
2690
+ /** Whether another bundle registered an input system with a different shape number. */
2691
+ function hasMismatchedPageInput() {
2692
+ const registered = typeof window === "undefined" ? void 0 : window.__DG__?.input;
2693
+ return registered !== void 0 && registered.api !== 1 && isInputSystem(registered.instance);
2694
+ }
2695
+ let claimedInput = null;
2696
+ /**
2697
+ * The page-wide input system, created on first use. `options` connect it to
2698
+ * the core client (its slug, the cloud save); the first call's options win.
2699
+ * When another client copy with a different shape owns the page, this copy
2700
+ * gets a private instance that takes the page over once its game defines
2701
+ * inputs.
2702
+ */
2703
+ function getInput(options = {}) {
2704
+ if (claimedInput !== null) return claimedInput;
2705
+ const isPrivate = hasMismatchedPageInput();
2706
+ claimedInput = claimSingleton({
2707
+ name: "input",
2708
+ version: HUB_CLIENT_VERSION,
2709
+ api: 1,
2710
+ create: () => createInputSystem(options, !isPrivate),
2711
+ isInstance: isInputSystem
2712
+ });
2713
+ return claimedInput;
2714
+ }
2715
+ //#endregion
2716
+ export { isPageInputControl as a, layoutControls as c, parseInputOverrides as i, boxesTouch as l, getInput as n, InputSystem as o, isInputSystem as r, Overlay as s, INPUT_SINGLETON_API as t, controlSize as u };
2717
+
2718
+ //# sourceMappingURL=input.js.map