@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,203 @@
1
+ /**
2
+ * The automatic layout of the on-screen touch controls (pure: no DOM). It is
3
+ * width-aware and collision-free, and keeps player-dragged spots across
4
+ * resizes.
5
+ */
6
+ import type { Anchor, VirtualDef } from '../input/types';
7
+ import { clusterBesideSticks, rowsBeyondSticks } from './layout-clusters';
8
+ import type { ClusterContext, StickSpan } from './layout-clusters';
9
+ import { anchorSides, boxesTouch, clampBetween, clampBox, isInside, isStickType, nearestFreeBox, unionBox } from './layout-geometry';
10
+ import type { Area, Insets, LayoutBox, LayoutItem } from './layout-geometry';
11
+
12
+ export type { LayoutItem } from './layout-geometry';
13
+
14
+ /** Layout options. */
15
+ export interface LayoutOptions {
16
+ /** Game insets (a HUD bar…): auto-placed controls keep clear of them. */
17
+ insets?: Partial<Insets>;
18
+ /** Device safe area (notch, home bar): every control stays inside it. */
19
+ safe?: Partial<Insets>;
20
+ /** Distance from the edges for auto-placed controls (default 22). */
21
+ margin?: number;
22
+ /** Space between auto-placed controls (default 14). */
23
+ gap?: number;
24
+ }
25
+
26
+ /** Controls that aren't sticks must not touch each other (px). */
27
+ const TOUCH_PADDING = 6;
28
+ const NO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
29
+
30
+ /** The placement state of one layout run. */
31
+ class LayoutPass {
32
+ readonly boxes: Record<string, LayoutBox> = {};
33
+ /** Obstacles (tap regions don't count). */
34
+ private readonly placed: LayoutBox[] = [];
35
+ private readonly stickBoxes = new Set<LayoutBox>();
36
+ private readonly gap: number;
37
+
38
+ constructor(gap: number) {
39
+ this.gap = gap;
40
+ }
41
+
42
+ commit(item: LayoutItem, box: LayoutBox): void {
43
+ this.boxes[item.id] = box;
44
+ if (item.type === 'tap') {
45
+ return;
46
+ }
47
+ this.placed.push(box);
48
+ if (isStickType(item.type)) {
49
+ this.stickBoxes.add(box);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether `box` is clear of `others`. A stick's whole box is its touch
55
+ * area, so everything keeps a full gap from it (a thumb on the rim must not
56
+ * catch a button); other controls just must not touch.
57
+ */
58
+ isFree(box: LayoutBox, others: readonly LayoutBox[] = this.placed): boolean {
59
+ return !others.some((other) => boxesTouch(box, other, this.stickBoxes.has(other) ? this.gap : TOUCH_PADDING));
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Place every control for a `viewportWidth` × `viewportHeight` viewport and
65
+ * return each control's box.
66
+ *
67
+ * 1. A game `pos` is honoured as given. A player-dragged control keeps its
68
+ * spot (as % of the viewport, re-clamped inside the safe area), nudged to
69
+ * the nearest free spot if it would land on (or within `gap` of) a stick.
70
+ * 2. Sticks sit in their anchor's corner, side by side inward.
71
+ * 3. Buttons cluster beside their corner's sticks, stacked in columns. If any
72
+ * cluster doesn't fit, every corner cluster that has sticks moves into rows
73
+ * above (below, for top corners) its sticks, first-declared button
74
+ * innermost. Any button that still collides moves to the nearest free spot.
75
+ */
76
+ export function layoutControls(items: readonly LayoutItem[], viewportWidth: number, viewportHeight: number, options: LayoutOptions = {}): Record<string, LayoutBox> {
77
+ const margin = options.margin ?? 22;
78
+ const gap = options.gap ?? 14;
79
+ const safe: Insets = { ...NO_INSETS, ...options.safe };
80
+ const insets: Insets = { ...NO_INSETS, ...options.insets };
81
+ const area: Area = {
82
+ left: safe.left + insets.left + margin,
83
+ top: safe.top + insets.top + margin,
84
+ right: viewportWidth - safe.right - insets.right - margin,
85
+ bottom: viewportHeight - safe.bottom - insets.bottom - margin,
86
+ };
87
+ const safeArea: Area = { left: safe.left, top: safe.top, right: viewportWidth - safe.right, bottom: viewportHeight - safe.bottom };
88
+ const context: ClusterContext = { area, gap, viewportWidth, viewportHeight };
89
+ const pass = new LayoutPass(gap);
90
+
91
+ // 1. Fixed spots. Player-dragged ones wait until the sticks are down.
92
+ const autoItems: LayoutItem[] = [];
93
+ const dragged: Array<{ item: LayoutItem; wanted: LayoutBox }> = [];
94
+ for (const item of items) {
95
+ if (item.override !== undefined) {
96
+ dragged.push({ item, wanted: {
97
+ x: clampBetween((item.override.x / 100) * viewportWidth, safe.left, viewportWidth - safe.right - item.w),
98
+ y: clampBetween((item.override.y / 100) * viewportHeight, safe.top, viewportHeight - safe.bottom - item.h),
99
+ w: item.w,
100
+ h: item.h,
101
+ } });
102
+ } else if (item.pos !== undefined) {
103
+ pass.commit(item, pinnedBox(item, item.pos, context));
104
+ } else {
105
+ autoItems.push(item);
106
+ }
107
+ }
108
+
109
+ const byAnchor = new Map<Anchor, LayoutItem[]>();
110
+ for (const item of autoItems) {
111
+ const list = byAnchor.get(item.place) ?? [];
112
+ list.push(item);
113
+ byAnchor.set(item.place, list);
114
+ }
115
+
116
+ // 2. Sticks in their corner, side by side inward.
117
+ const sticksByAnchor = new Map<Anchor, StickSpan>();
118
+ for (const [anchor, list] of byAnchor) {
119
+ sticksByAnchor.set(anchor, placeSticks(list, anchor, context, pass));
120
+ }
121
+ // A dragged control keeps its spot unless that now lands on a stick or a fixed control.
122
+ for (const { item, wanted } of dragged) {
123
+ const isClear = item.type === 'tap' || pass.isFree(wanted);
124
+ pass.commit(item, isClear ? wanted : nearestFreeBox(wanted, safeArea, (box) => pass.isFree(box)) ?? wanted);
125
+ }
126
+
127
+ // 3a. The classic cluster for every anchor's buttons.
128
+ const classic = new Map<string, LayoutBox>();
129
+ for (const [anchor, list] of byAnchor) {
130
+ const buttons = list.filter((item) => !isStickType(item.type));
131
+ if (buttons.length > 0) {
132
+ clusterBesideSticks(buttons, anchorSides(anchor), sticksByAnchor.get(anchor) ?? EMPTY_SPAN, context, classic);
133
+ }
134
+ }
135
+ // Does every cluster fit (inside the area, clear of sticks, fixed controls and the other buttons)?
136
+ const solidButtons = autoItems.filter((item) => !isStickType(item.type) && item.type !== 'tap');
137
+ const classicBoxes = solidButtons.flatMap((item) => classic.get(item.id) ?? []);
138
+ const isCompact = classicBoxes.some((box) => !isInside(box, area) || !pass.isFree(box) || !pass.isFree(box, classicBoxes.filter((other) => other !== box)));
139
+
140
+ // 3b. Commit: classic spots, or rows over the sticks in compact mode. Taps keep their spot (they're meant to overlap).
141
+ const lost: Array<{ item: LayoutItem; wanted: LayoutBox }> = [];
142
+ for (const [anchor, list] of byAnchor) {
143
+ const buttons = list.filter((item) => !isStickType(item.type));
144
+ const sides = anchorSides(anchor);
145
+ const span = sticksByAnchor.get(anchor)?.span ?? null;
146
+ const rows = isCompact && span !== null && sides.horizontal !== 'mid' && sides.vertical !== 'mid'
147
+ ? rowsBeyondSticks(buttons.filter((item) => item.type !== 'tap'), sides, span, context)
148
+ : null;
149
+ for (const item of buttons) {
150
+ const wanted = rows?.get(item.id) ?? classic.get(item.id);
151
+ if (wanted === undefined) {
152
+ continue;
153
+ }
154
+ if (item.type === 'tap' || (isInside(wanted, area) && pass.isFree(wanted))) {
155
+ pass.commit(item, wanted);
156
+ } else {
157
+ lost.push({ item, wanted });
158
+ }
159
+ }
160
+ }
161
+ // 3c. Anything still colliding goes to the nearest free spot.
162
+ for (const { item, wanted } of lost) {
163
+ pass.commit(item, nearestFreeBox(wanted, area, (box) => pass.isFree(box)) ?? clampBox(wanted, area));
164
+ }
165
+ return pass.boxes;
166
+ }
167
+
168
+ const EMPTY_SPAN: StickSpan = { span: null, reserve: 0 };
169
+
170
+ /** A control pinned by `pos` (anchor sides fill in a missing axis). */
171
+ function pinnedBox(item: LayoutItem, pos: NonNullable<VirtualDef['pos']>, context: ClusterContext): LayoutBox {
172
+ const { area, viewportWidth, viewportHeight } = context;
173
+ const sides = anchorSides(item.place);
174
+ const anchoredLeft = sides.horizontal === 'left' ? area.left : sides.horizontal === 'right' ? area.right - item.w : (viewportWidth - item.w) / 2;
175
+ const anchoredTop = sides.vertical === 'top' ? area.top : sides.vertical === 'bottom' ? area.bottom - item.h : (viewportHeight - item.h) / 2;
176
+ const left = pos.left ?? (pos.right === undefined ? anchoredLeft : viewportWidth - pos.right - item.w);
177
+ const top = pos.top ?? (pos.bottom === undefined ? anchoredTop : viewportHeight - pos.bottom - item.h);
178
+ return { x: clampBetween(left, 0, viewportWidth - item.w), y: clampBetween(top, 0, viewportHeight - item.h), w: item.w, h: item.h };
179
+ }
180
+
181
+ /** Place an anchor's sticks side by side from its corner; returns their span and the width they reserve. */
182
+ function placeSticks(list: readonly LayoutItem[], anchor: Anchor, context: ClusterContext, pass: LayoutPass): StickSpan {
183
+ const { area, gap, viewportWidth, viewportHeight } = context;
184
+ const sides = anchorSides(anchor);
185
+ let reserve = 0;
186
+ let span: LayoutBox | null = null;
187
+ for (const item of list) {
188
+ if (!isStickType(item.type)) {
189
+ continue;
190
+ }
191
+ const nudgeX = item.offset?.x ?? 0;
192
+ const nudgeY = item.offset?.y ?? 0;
193
+ const left = sides.horizontal === 'left' ? area.left + reserve + nudgeX
194
+ : sides.horizontal === 'right' ? area.right - reserve - item.w - nudgeX : (viewportWidth - item.w) / 2 + nudgeX;
195
+ const top = sides.vertical === 'top' ? area.top + nudgeY
196
+ : sides.vertical === 'bottom' ? area.bottom - item.h - nudgeY : (viewportHeight - item.h) / 2 + nudgeY;
197
+ const box = { x: left, y: top, w: item.w, h: item.h };
198
+ pass.commit(item, box);
199
+ span = span === null ? { ...box } : unionBox(span, box);
200
+ reserve += item.w + gap;
201
+ }
202
+ return { span, reserve };
203
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A group of window listeners removed together, so rebuilding the on-screen
3
+ * controls doesn't leave window listeners behind for removed elements.
4
+ * (Listeners on the controls themselves go away with the elements.)
5
+ */
6
+ export class ListenerScope {
7
+ private readonly removers: Array<() => void> = [];
8
+
9
+ /** Listen on `window` until {@link ListenerScope.dispose}. */
10
+ listenWindow<Name extends keyof WindowEventMap>(type: Name, listener: (event: WindowEventMap[Name]) => void, options?: AddEventListenerOptions): void {
11
+ window.addEventListener(type, listener, options);
12
+ this.removers.push(() => {
13
+ window.removeEventListener(type, listener);
14
+ });
15
+ }
16
+
17
+ /** Remove every listener added through this scope. */
18
+ dispose(): void {
19
+ for (const remove of this.removers.splice(0)) {
20
+ remove();
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The on-screen keyboard for text fields, usable by touch, mouse or gamepad
3
+ * (the stick or d-pad moves between keys, A presses, B or Start closes).
4
+ */
5
+ import { RepeatingEdges, RisingEdges } from '../input/edges';
6
+ import { isAnyPadButtonPressed, PAD_BUTTON, readGamepads } from '../input/gamepad';
7
+ import type { Direction } from '../input/gamepad';
8
+ import { findNearestInDirection, rectOf } from '../input/spatial';
9
+ import { findElements } from './dom';
10
+
11
+ const LETTER_ROWS = ['1234567890', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
12
+ const STICK_THRESHOLD = 0.5;
13
+
14
+ /** One key's markup. */
15
+ function keyHtml(label: string, key: string, extraClass = ''): string {
16
+ return `<button class="k ${extraClass}" data-k="${key}">${label}</button>`;
17
+ }
18
+
19
+ 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('')}`
20
+ + `<div class="kb-row">${keyHtml('⇧', 'shift', 'wide')}${keyHtml('space', ' ', 'space')}${keyHtml('⌫', 'back', 'wide')}${keyHtml('↵', 'enter', 'wide')}${keyHtml('✕', 'close', 'wide')}</div></div>`;
21
+
22
+ /** The direction the pads point, from the summed stick and d-pad. */
23
+ function heldDirection(pads: readonly Gamepad[]): Direction | null {
24
+ let horizontal = 0;
25
+ let vertical = 0;
26
+ for (const pad of pads) {
27
+ horizontal += (pad.axes[0] ?? 0) - Number(pad.buttons[PAD_BUTTON.LEFT]?.pressed === true) + Number(pad.buttons[PAD_BUTTON.RIGHT]?.pressed === true);
28
+ vertical += (pad.axes[1] ?? 0) - Number(pad.buttons[PAD_BUTTON.UP]?.pressed === true) + Number(pad.buttons[PAD_BUTTON.DOWN]?.pressed === true);
29
+ }
30
+ if (Math.abs(horizontal) <= STICK_THRESHOLD && Math.abs(vertical) <= STICK_THRESHOLD) {
31
+ return null;
32
+ }
33
+ if (Math.abs(horizontal) > Math.abs(vertical)) {
34
+ return horizontal > 0 ? 'right' : 'left';
35
+ }
36
+ return vertical > 0 ? 'down' : 'up';
37
+ }
38
+
39
+ /** The on-screen keyboard (one at a time, inside the overlay's shadow root). */
40
+ export class OnScreenKeyboard {
41
+ private readonly root: ShadowRoot;
42
+ private panel: HTMLDivElement | null = null;
43
+ private target: HTMLInputElement | HTMLTextAreaElement | null = null;
44
+ private keys: HTMLElement[] = [];
45
+ private focusedIndex = 0;
46
+ private isShifted = false;
47
+ private readonly repeats = new RepeatingEdges();
48
+ private readonly presses = new RisingEdges();
49
+
50
+ constructor(root: ShadowRoot) {
51
+ this.root = root;
52
+ }
53
+
54
+ /** Whether the keyboard is showing. */
55
+ get isOpen(): boolean {
56
+ return this.panel !== null;
57
+ }
58
+
59
+ /** Open it for `target` (replacing any open keyboard). */
60
+ open(target: HTMLInputElement | HTMLTextAreaElement | null): void {
61
+ this.close();
62
+ this.target = target;
63
+ this.isShifted = false;
64
+ this.focusedIndex = 0;
65
+ const panel = document.createElement('div');
66
+ panel.className = 'kb';
67
+ panel.innerHTML = KEYBOARD_HTML;
68
+ this.root.appendChild(panel);
69
+ this.panel = panel;
70
+ this.keys = findElements(panel, '.k', HTMLElement);
71
+ for (const key of this.keys) {
72
+ const press = (event: Event): void => {
73
+ event.preventDefault();
74
+ this.press(key.dataset['k'] ?? '');
75
+ };
76
+ key.addEventListener('mousedown', press);
77
+ key.addEventListener('touchstart', press, { passive: false });
78
+ }
79
+ this.highlightFocusedKey();
80
+ }
81
+
82
+ /** Close it. */
83
+ close(): void {
84
+ this.panel?.remove();
85
+ this.panel = null;
86
+ this.target = null;
87
+ this.keys = [];
88
+ }
89
+
90
+ /** Called every frame while open: gamepad navigation. */
91
+ pollGamepads(): void {
92
+ if (this.panel === null) {
93
+ return;
94
+ }
95
+ const pads = readGamepads();
96
+ const direction = heldDirection(pads);
97
+ if (this.repeats.fires('move', direction !== null) && direction !== null) {
98
+ this.moveFocus(direction);
99
+ }
100
+ if (this.presses.rose('act', isAnyPadButtonPressed(pads, PAD_BUTTON.A))) {
101
+ this.press(this.keys[this.focusedIndex]?.dataset['k'] ?? '');
102
+ }
103
+ const isClosing = isAnyPadButtonPressed(pads, PAD_BUTTON.B) || isAnyPadButtonPressed(pads, PAD_BUTTON.START);
104
+ if (this.presses.rose('close', isClosing)) {
105
+ this.close();
106
+ }
107
+ }
108
+
109
+ private press(key: string): void {
110
+ if (key === 'close' || key === 'enter') {
111
+ this.close();
112
+ return;
113
+ }
114
+ if (key === 'shift') {
115
+ this.isShifted = !this.isShifted;
116
+ return;
117
+ }
118
+ const target = this.target;
119
+ if (target === null || key === '') {
120
+ return;
121
+ }
122
+ target.value = key === 'back' ? target.value.slice(0, -1) : target.value + (this.isShifted ? key.toUpperCase() : key);
123
+ target.dispatchEvent(new Event('input', { bubbles: true }));
124
+ }
125
+
126
+ private moveFocus(direction: Direction): void {
127
+ const next = findNearestInDirection(this.keys.map(rectOf), this.focusedIndex, direction);
128
+ if (next >= 0) {
129
+ this.focusedIndex = next;
130
+ this.highlightFocusedKey();
131
+ }
132
+ }
133
+
134
+ private highlightFocusedKey(): void {
135
+ this.keys.forEach((key, index) => key.classList.toggle('kfocus', index === this.focusedIndex));
136
+ }
137
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The input system's DOM overlay: the on-screen touch controls, the
3
+ * navigation highlight ring, the on-screen keyboard and the Controls screen.
4
+ * Everything lives in one style-isolated shadow root layered over the game,
5
+ * so it works the same for canvas and HTML games.
6
+ */
7
+ import type { NavHighlighter } from '../input/nav-state';
8
+ import type { InputConfig, InputOverrides, Rect, VirtualDef } from '../input/types';
9
+ import { readField } from '../realtime/cross-bundle';
10
+ import { ControlsPanel } from './controls-panel';
11
+ import type { ControlsPanelHost } from './controls-panel';
12
+ import { pageBody, placeAt, requireElement } from './dom';
13
+ import { layoutControls } from './layout';
14
+ import { controlSize } from './layout-geometry';
15
+ import type { Insets, LayoutItem } from './layout-geometry';
16
+ import { ListenerScope } from './listener-scope';
17
+ import { OnScreenKeyboard } from './on-screen-keyboard';
18
+ import { OVERLAY_CSS } from './styles';
19
+ import { createVirtualControl } from './virtual-controls';
20
+ import type { VirtualControlCallbacks } from './virtual-controls';
21
+
22
+ /** One built on-screen control. */
23
+ interface BuiltControl {
24
+ readonly definition: VirtualDef;
25
+ readonly group: string;
26
+ readonly element: HTMLElement;
27
+ }
28
+
29
+ /** The window event the race HUD fires with the bands it covers. */
30
+ const RACE_HUD_EVENT = 'hub:race-hud';
31
+ /** Wait for the browser to settle after a rotation before re-laying out. */
32
+ const ORIENTATION_SETTLE_MS = 200;
33
+
34
+ /** A number field of an event detail, or 0. */
35
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a window event detail from another bundle (the race HUD)
36
+ function numberField(detail: unknown, key: string): number {
37
+ const field = Number(readField(detail, key));
38
+ return Number.isFinite(field) ? field : 0;
39
+ }
40
+
41
+ /** The overlay (one per input system). */
42
+ export class Overlay implements NavHighlighter {
43
+ private readonly shadowHost: HTMLDivElement;
44
+ private readonly root: ShadowRoot;
45
+ private readonly controlsLayer: HTMLDivElement;
46
+ private readonly ring: HTMLDivElement;
47
+ private readonly keyboard: OnScreenKeyboard;
48
+ private readonly controlsPanel: ControlsPanel;
49
+ private controls: BuiltControl[] = [];
50
+ private controlScope = new ListenerScope();
51
+ private overrides: InputOverrides = {};
52
+ private gameInsets: Partial<Insets> = {};
53
+ private raceHudBands = { top: 0, bottom: 0 };
54
+ private safeAreaProbe: HTMLDivElement | null = null;
55
+
56
+ constructor() {
57
+ this.shadowHost = document.createElement('div');
58
+ this.shadowHost.id = 'hub-input-root';
59
+ this.root = this.shadowHost.attachShadow({ mode: 'open' });
60
+ this.root.innerHTML = `<style>${OVERLAY_CSS}</style>
61
+ <div class="layer controls"></div>
62
+ <div class="ring" hidden></div>`;
63
+ this.controlsLayer = requireElement(this.root, '.controls', HTMLDivElement);
64
+ this.ring = requireElement(this.root, '.ring', HTMLDivElement);
65
+ this.keyboard = new OnScreenKeyboard(this.root);
66
+ this.controlsPanel = new ControlsPanel(this.root, this.shadowHost);
67
+ (pageBody() ?? document.documentElement).appendChild(this.shadowHost);
68
+ window.addEventListener('resize', () => {
69
+ this.layout();
70
+ });
71
+ window.addEventListener('orientationchange', () => {
72
+ setTimeout(() => {
73
+ this.layout();
74
+ }, ORIENTATION_SETTLE_MS);
75
+ });
76
+ // Keep top and bottom controls clear of the hub's race HUD while it shows.
77
+ window.addEventListener(RACE_HUD_EVENT, (event) => {
78
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the race HUD's event detail, read field by field
79
+ const detail: unknown = event instanceof CustomEvent ? event.detail : null;
80
+ const isVisible = readField(detail, 'visible') === true;
81
+ const bands = { top: isVisible ? numberField(detail, 'top') : 0, bottom: isVisible ? numberField(detail, 'bottom') : 0 };
82
+ if (bands.top !== this.raceHudBands.top || bands.bottom !== this.raceHudBands.bottom) {
83
+ this.raceHudBands = bands;
84
+ this.layout();
85
+ }
86
+ });
87
+ }
88
+
89
+ /** (Re)build every on-screen control from the config and the player's overrides. */
90
+ build(config: InputConfig, overrides: InputOverrides, callbacks: VirtualControlCallbacks): void {
91
+ this.overrides = overrides;
92
+ this.controlScope.dispose();
93
+ this.controlScope = new ListenerScope();
94
+ this.controlsLayer.innerHTML = '';
95
+ this.controls = [];
96
+ const environment = { callbacks, scope: this.controlScope, isEditing: () => this.controlsPanel.isEditing };
97
+ for (const [group, groupDefinition] of Object.entries(config.groups)) {
98
+ for (const declared of groupDefinition.virtual ?? []) {
99
+ const definition: VirtualDef = { ...declared, ...overrides.virtual?.[declared.id] };
100
+ const element = createVirtualControl(definition, environment);
101
+ Reflect.set(element, '__group', group);
102
+ this.controlsLayer.appendChild(element);
103
+ this.controls.push({ definition, group, element });
104
+ }
105
+ }
106
+ this.layout();
107
+ }
108
+
109
+ /** Replace a button or tap control's content at runtime. Listeners live on the wrapper, so they survive. */
110
+ setControlHtml(id: string, html: string): void {
111
+ const control = this.controls.find((candidate) => candidate.definition.id === id);
112
+ if (control !== undefined && (control.definition.type === 'button' || control.definition.type === 'tap')) {
113
+ control.element.innerHTML = html;
114
+ }
115
+ }
116
+
117
+ /** Show controls of the enabled groups (or of every group, with `null`, while editing). */
118
+ setVirtualVisible(isVisible: boolean, enabledGroups: ReadonlySet<string> | null): void {
119
+ for (const control of this.controls) {
120
+ const isShown = isVisible && (enabledGroups === null || enabledGroups.has(control.group));
121
+ control.element.style.display = isShown ? '' : 'none';
122
+ }
123
+ }
124
+
125
+ /** Game insets (px) auto-placed controls keep clear of, e.g. a top HUD bar. Merged with earlier calls. */
126
+ setInsets(insets: Partial<Insets>): void {
127
+ this.gameInsets = { ...this.gameInsets, ...insets };
128
+ this.layout();
129
+ }
130
+
131
+ /** Re-place every control for the current viewport, keeping the player's dragged spots. */
132
+ layout(): void {
133
+ if (this.controls.length === 0) {
134
+ return;
135
+ }
136
+ const insets: Insets = {
137
+ top: (this.gameInsets.top ?? 0) + this.raceHudBands.top,
138
+ right: this.gameInsets.right ?? 0,
139
+ bottom: (this.gameInsets.bottom ?? 0) + this.raceHudBands.bottom,
140
+ left: this.gameInsets.left ?? 0,
141
+ };
142
+ const items: LayoutItem[] = this.controls.map(({ definition }) => {
143
+ const dragged = this.overrides.virtual?.[definition.id];
144
+ return {
145
+ id: definition.id,
146
+ type: definition.type,
147
+ place: definition.place,
148
+ ...controlSize(definition),
149
+ pos: definition.pos,
150
+ offset: definition.offset,
151
+ override: dragged?.x !== undefined && dragged.y !== undefined ? { x: dragged.x, y: dragged.y } : undefined,
152
+ };
153
+ });
154
+ const boxes = layoutControls(items, window.innerWidth, window.innerHeight, { insets, safe: this.readSafeArea() });
155
+ for (const control of this.controls) {
156
+ const box = boxes[control.definition.id];
157
+ if (box !== undefined) {
158
+ placeAt(control.element, box.x, box.y);
159
+ }
160
+ }
161
+ }
162
+
163
+ /** Ring the highlighted element. */
164
+ showHighlight(bounds: Rect): void {
165
+ this.ring.hidden = false;
166
+ this.ring.style.left = `${String(bounds.x)}px`;
167
+ this.ring.style.top = `${String(bounds.y)}px`;
168
+ this.ring.style.width = `${String(bounds.width)}px`;
169
+ this.ring.style.height = `${String(bounds.height)}px`;
170
+ }
171
+
172
+ /** Remove the ring. */
173
+ hideHighlight(): void {
174
+ this.ring.hidden = true;
175
+ }
176
+
177
+ /** Open the on-screen keyboard for a text field. */
178
+ openKeyboard(target: HTMLInputElement | HTMLTextAreaElement | null): void {
179
+ this.keyboard.open(target);
180
+ }
181
+
182
+ /** Whether the on-screen keyboard is showing. */
183
+ get isKeyboardOpen(): boolean {
184
+ return this.keyboard.isOpen;
185
+ }
186
+
187
+ /** Open (or close) the Controls screen. */
188
+ toggleControls(config: InputConfig | null, host: ControlsPanelHost): void {
189
+ this.controlsPanel.toggle(config, host);
190
+ }
191
+
192
+ /** Per-frame work: gamepad navigation of the keyboard. */
193
+ afterFrame(): void {
194
+ this.keyboard.pollGamepads();
195
+ }
196
+
197
+ /** The device safe-area insets (notch, home bar), via a probe element. */
198
+ private readSafeArea(): Insets {
199
+ if (this.safeAreaProbe === null) {
200
+ this.safeAreaProbe = document.createElement('div');
201
+ this.safeAreaProbe.className = 'safe-probe';
202
+ this.root.appendChild(this.safeAreaProbe);
203
+ }
204
+ const style = getComputedStyle(this.safeAreaProbe);
205
+ const pixels = (value: string): number => parseFloat(value) || 0;
206
+ return { top: pixels(style.paddingTop), right: pixels(style.paddingRight), bottom: pixels(style.paddingBottom), left: pixels(style.paddingLeft) };
207
+ }
208
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The overlay's styles (inside its shadow root, so games can't restyle it).
3
+ */
4
+
5
+ /** The hub accent colour. */
6
+ export const ACCENT_COLOR = '#ff5f3b';
7
+
8
+ /** The overlay stylesheet. */
9
+ export const OVERLAY_CSS = `
10
+ :host { all: initial; }
11
+ .layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483000; font-family: 'Space Mono', ui-monospace, monospace; }
12
+ .vc { position: fixed; pointer-events: auto; touch-action: none; user-select: none; -webkit-user-select: none;
13
+ display: grid; place-items: center; color: #fff; }
14
+ .vc-button, .vc-tap { background: var(--vc-bg, rgba(20,22,30,.55)); border: 2px solid var(--vc-color);
15
+ font: 700 15px/1 monospace; box-shadow: 0 2px 10px rgba(0,0,0,.4); }
16
+ .vc-button.pressed, .vc-tap.pressed { background: var(--vc-color); color: #0d0f14; }
17
+ .vc-transparent { background: transparent; border-color: transparent; }
18
+ .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); }
19
+ .vc-joystick .knob, .vc-dpad .knob { position: absolute; width: 44%; height: 44%; border-radius: 50%; background: var(--vc-color); opacity: .9; }
20
+ :host(.editing) .vc { outline: 2px dashed #38d6ff; cursor: move; }
21
+ /* While editing the layout the panel shrinks to a small bar at the top so the
22
+ controls underneath can be dragged. */
23
+ .edit-bar { display: none; }
24
+ :host(.editing) .panel { background: transparent; pointer-events: none; place-items: start center; }
25
+ :host(.editing) .panel-card { pointer-events: auto; width: auto; margin-top: max(10px, env(safe-area-inset-top, 0px)); padding: 8px 10px; }
26
+ :host(.editing) .panel-card > :not(.edit-bar) { display: none; }
27
+ :host(.editing) .edit-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
28
+ :host(.editing) .edit-bar .btn { margin: 0; }
29
+ .safe-probe { position: fixed; left: 0; top: 0; width: 0; height: 0; visibility: hidden; pointer-events: none;
30
+ 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); }
31
+ .ring { position: fixed; pointer-events: none; z-index: 2147483200; border: 3px solid ${ACCENT_COLOR}; border-radius: 8px;
32
+ box-shadow: 0 0 0 2px rgba(0,0,0,.4); transition: left .08s, top .08s, width .08s, height .08s; }
33
+ .ring[hidden] { display: none; }
34
+ .kb { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483400; background: #0d0f14ee; padding: 10px;
35
+ pointer-events: auto; border-top: 1px solid #2a3142; }
36
+ .kb-rows { display: flex; flex-direction: column; gap: 6px; max-width: 760px; margin: 0 auto; }
37
+ .kb-row { display: flex; gap: 6px; justify-content: center; }
38
+ .kb .k { flex: 1; min-width: 0; max-width: 64px; height: 46px; border-radius: 8px; border: 1px solid #2a3142;
39
+ background: #11141c; color: #e8e6df; font: 16px monospace; cursor: pointer; }
40
+ .kb .k.space { max-width: 280px; } .kb .k.wide { max-width: 90px; }
41
+ .kb .k.kfocus { border-color: ${ACCENT_COLOR}; box-shadow: 0 0 0 2px ${ACCENT_COLOR}; }
42
+ .panel { position: fixed; inset: 0; z-index: 2147483500; display: grid; place-items: center; background: rgba(0,0,0,.6); pointer-events: auto;
43
+ font-family: 'Space Mono', ui-monospace, monospace; }
44
+ .panel-card { background: #0d0f14; color: #e8e6df; border: 1px solid #2a3142; border-radius: 12px; padding: 18px;
45
+ width: min(420px, 92vw); max-height: 86vh; overflow: auto; box-shadow: 0 12px 44px rgba(0,0,0,.6); }
46
+ .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; }
47
+ .cr { display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 4px 0; border-bottom: 1px solid #1a1f2b; }
48
+ .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; }
49
+ .chk { display: flex; gap: 8px; align-items: center; font-size: 13px; margin-bottom: 10px; }
50
+ .btn { background: ${ACCENT_COLOR}; color: #0d0f14; border: 0; border-radius: 8px; padding: 8px 12px; font: 13px monospace; cursor: pointer; margin: 4px 4px 0 0; }
51
+ .btn.editlayout, .btn.reset { background: transparent; color: #e8e6df; border: 1px solid #2a3142; }
52
+ .panel-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
53
+ .hint { color: #38d6ff; font-size: 12px; min-height: 1em; }
54
+ `;