@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,191 @@
1
+ /**
2
+ * One navigable list: the highlighted element, stepping and spatial moves,
3
+ * and activating it. The list is re-queried on every interaction because the
4
+ * DOM can change under it.
5
+ */
6
+ import type { Direction } from './gamepad';
7
+ import { findNearestInDirection, rectOf } from './spatial';
8
+ import type { NavBlurEvent, NavElement, NavElementKind, NavFocusEvent, NavTarget, Rect } from './types';
9
+
10
+ /** What a navigable list draws with (the overlay). */
11
+ export interface NavHighlighter {
12
+ /** Ring the highlighted element. */
13
+ showHighlight(bounds: Rect): void;
14
+ /** Remove the ring. */
15
+ hideHighlight(): void;
16
+ /** Open the on-screen keyboard for a text field. */
17
+ openKeyboard(target: HTMLInputElement | HTMLTextAreaElement | null): void;
18
+ }
19
+
20
+ /** Where a list reports focus changes and activations. */
21
+ export interface NavListeners {
22
+ /** An element gained the highlight. */
23
+ onFocus(event: NavFocusEvent): void;
24
+ /** An element lost the highlight. */
25
+ onBlur(event: NavBlurEvent): void;
26
+ /** The player activated an element. */
27
+ onAct(event: NavFocusEvent): void;
28
+ }
29
+
30
+ const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'password', 'number']);
31
+
32
+ /** What kind of element a DOM node is, for `act`. */
33
+ function kindOf(element: HTMLElement): NavElementKind {
34
+ if (element instanceof HTMLInputElement) {
35
+ if (element.type === 'range') {
36
+ return 'range';
37
+ }
38
+ if (TEXT_INPUT_TYPES.has(element.type)) {
39
+ return 'text';
40
+ }
41
+ }
42
+ return element instanceof HTMLTextAreaElement ? 'text' : 'button';
43
+ }
44
+
45
+ /** A navigable element's bounds. */
46
+ function boundsOf(element: NavElement): Rect {
47
+ if (element.getBounds !== undefined) {
48
+ return element.getBounds();
49
+ }
50
+ return element.el === undefined ? { x: 0, y: 0, width: 0, height: 0 } : rectOf(element.el);
51
+ }
52
+
53
+ /** The text field a navigable element edits, if it is one. */
54
+ function textFieldOf(element: NavElement): HTMLInputElement | HTMLTextAreaElement | null {
55
+ return element.el instanceof HTMLInputElement || element.el instanceof HTMLTextAreaElement ? element.el : null;
56
+ }
57
+
58
+ /** The highlight state of one navigable list. */
59
+ export class NavState {
60
+ private elements: NavElement[] = [];
61
+ private index = -1;
62
+ private readonly queryElements: () => NavTarget[];
63
+ private readonly shouldWrap: boolean;
64
+
65
+ constructor(queryElements: () => NavTarget[], shouldWrap = true) {
66
+ this.queryElements = queryElements;
67
+ this.shouldWrap = shouldWrap;
68
+ }
69
+
70
+ /** Re-read the list, highlighting the first element if nothing is. */
71
+ requery(highlighter: NavHighlighter | null, listeners: NavListeners): void {
72
+ this.elements = this.resolve();
73
+ if (this.index >= this.elements.length) {
74
+ this.index = this.elements.length - 1;
75
+ }
76
+ if (this.index < 0 && this.elements.length > 0) {
77
+ this.index = 0;
78
+ this.focusCurrent(listeners);
79
+ }
80
+ this.position(highlighter);
81
+ }
82
+
83
+ /** Drop the highlight. */
84
+ blur(highlighter: NavHighlighter | null): void {
85
+ this.elements[this.index]?.onBlur?.();
86
+ this.index = -1;
87
+ highlighter?.hideHighlight();
88
+ }
89
+
90
+ /** Step to the next (+1) or previous (-1) element. */
91
+ step(delta: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
92
+ this.elements = this.resolve();
93
+ const count = this.elements.length;
94
+ if (count === 0) {
95
+ return;
96
+ }
97
+ this.blurCurrent(listeners);
98
+ let next = this.index + delta;
99
+ if (next < 0) {
100
+ next = this.shouldWrap ? count - 1 : 0;
101
+ }
102
+ if (next >= count) {
103
+ next = this.shouldWrap ? 0 : count - 1;
104
+ }
105
+ this.moveTo(next, highlighter, listeners);
106
+ }
107
+
108
+ /** Move to the nearest element in `direction`; up and down spill over to the previous and next column. */
109
+ moveInDirection(direction: Direction, highlighter: NavHighlighter | null, listeners: NavListeners): void {
110
+ this.elements = this.resolve();
111
+ if (this.elements.length === 0) {
112
+ return;
113
+ }
114
+ if (this.index < 0) {
115
+ this.moveTo(0, highlighter, listeners);
116
+ return;
117
+ }
118
+ const rects = this.elements.map(boundsOf);
119
+ let target = findNearestInDirection(rects, this.index, direction);
120
+ // Nothing directly that way: up falls through to the left, down to the right, so columns never dead-end.
121
+ const spillover = { up: 'left', down: 'right', left: null, right: null } as const;
122
+ const fallback = spillover[direction];
123
+ if (target < 0 && fallback !== null) {
124
+ target = findNearestInDirection(rects, this.index, fallback);
125
+ }
126
+ if (target >= 0) {
127
+ this.blurCurrent(listeners);
128
+ this.moveTo(target, highlighter, listeners);
129
+ }
130
+ }
131
+
132
+ /** Activate the highlighted element: open the keyboard for text, click anything else. */
133
+ act(highlighter: NavHighlighter | null, listeners: NavListeners): void {
134
+ this.elements = this.resolve();
135
+ const current = this.elements[this.index];
136
+ if (current === undefined) {
137
+ return;
138
+ }
139
+ if (current.kind === 'text') {
140
+ highlighter?.openKeyboard(textFieldOf(current));
141
+ } else {
142
+ current.el?.click();
143
+ }
144
+ current.onAct?.();
145
+ listeners.onAct({ id: current.id, el: current.el, kind: current.kind, index: this.index });
146
+ // Acting can change the elements.
147
+ this.elements = this.resolve();
148
+ this.position(highlighter);
149
+ }
150
+
151
+ /** Move the ring to the highlighted element (or hide it). */
152
+ position(highlighter: NavHighlighter | null): void {
153
+ if (highlighter === null) {
154
+ return;
155
+ }
156
+ const current = this.elements[this.index];
157
+ if (current === undefined) {
158
+ highlighter.hideHighlight();
159
+ } else {
160
+ highlighter.showHighlight(boundsOf(current));
161
+ }
162
+ }
163
+
164
+ private resolve(): NavElement[] {
165
+ return this.queryElements().map((target) => (target instanceof HTMLElement ? { el: target, kind: kindOf(target) } : target));
166
+ }
167
+
168
+ private moveTo(index: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
169
+ this.index = index;
170
+ this.focusCurrent(listeners);
171
+ this.position(highlighter);
172
+ }
173
+
174
+ private focusCurrent(listeners: NavListeners): void {
175
+ const current = this.elements[this.index];
176
+ if (current === undefined) {
177
+ return;
178
+ }
179
+ current.el?.focus({ preventScroll: false });
180
+ current.onFocus?.();
181
+ listeners.onFocus({ id: current.id, el: current.el, kind: current.kind, index: this.index });
182
+ }
183
+
184
+ private blurCurrent(listeners: NavListeners): void {
185
+ const current = this.elements[this.index];
186
+ if (current?.onBlur !== undefined) {
187
+ current.onBlur();
188
+ listeners.onBlur({ id: current.id, index: this.index });
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Gamepad navigation the input system drives each frame: the hub menu while
3
+ * it is open, a game's own menus opted in with `autoNavigate`, and the
4
+ * navigable groups a game declares.
5
+ */
6
+ import type { RepeatingEdges, RisingEdges } from './edges';
7
+ import { anyPadDirections, AXIS_DEADZONE, isAnyPadButtonPressed, PAD_BUTTON } from './gamepad';
8
+ import type { Direction } from './gamepad';
9
+ import { NavState } from './nav-state';
10
+ import type { NavHighlighter, NavListeners } from './nav-state';
11
+ import type { MenuBridge, NavigableDef, NavTarget } from './types';
12
+
13
+ /** What the navigator reads from the input system. */
14
+ export interface NavigatorContext {
15
+ readonly gamepads: readonly Gamepad[];
16
+ readonly repeats: RepeatingEdges;
17
+ readonly presses: RisingEdges;
18
+ readonly listeners: NavListeners;
19
+ /** The overlay, created on demand. */
20
+ highlighter(): NavHighlighter;
21
+ /** Whether an input is held (≥ 0.5). */
22
+ isPressed(inputName: string): boolean;
23
+ /** Whether an input went down this frame. */
24
+ wentDown(inputName: string): boolean;
25
+ }
26
+
27
+ const DIRECTIONS: readonly Direction[] = ['up', 'down', 'left', 'right'];
28
+
29
+ /** The visible focusables of the hub menu. */
30
+ function menuFocusables(root: ShadowRoot | null): HTMLElement[] {
31
+ if (root === null) {
32
+ return [];
33
+ }
34
+ return [...root.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, [tabindex]')]
35
+ .filter((element) => element.getClientRects().length > 0);
36
+ }
37
+
38
+ /** Visible elements matching a CSS selector. */
39
+ function visibleMatches(selector: string): HTMLElement[] {
40
+ return [...document.querySelectorAll<HTMLElement>(selector)].filter((element) => element.getClientRects().length > 0);
41
+ }
42
+
43
+ /** A navigable group with its highlight state. */
44
+ interface GroupNavigation {
45
+ readonly definition: NavigableDef;
46
+ readonly state: NavState;
47
+ }
48
+
49
+ /** The menu, auto-navigate and group navigation of one input system. */
50
+ export class Navigator {
51
+ private menuState: NavState | null = null;
52
+ private autoNavigateQuery: (() => NavTarget[]) | null = null;
53
+ private autoNavigateState: NavState | null = null;
54
+ private autoNavigateBack: (() => void) | null = null;
55
+ private readonly groups = new Map<string, GroupNavigation>();
56
+
57
+ /** Forget the declared groups (a new `define`). */
58
+ clearGroups(): void {
59
+ this.groups.clear();
60
+ }
61
+
62
+ /** Declare a navigable group. */
63
+ addGroup(group: string, definition: NavigableDef): void {
64
+ this.groups.set(group, { definition, state: new NavState(definition.elements, definition.wrap !== false) });
65
+ }
66
+
67
+ /** A group was enabled: highlight its first element. */
68
+ refreshGroup(group: string, context: NavigatorContext): void {
69
+ this.groups.get(group)?.state.requery(context.highlighter(), context.listeners);
70
+ }
71
+
72
+ /** A group was disabled: drop its highlight. */
73
+ blurGroup(group: string, highlighter: NavHighlighter | null): void {
74
+ this.groups.get(group)?.state.blur(highlighter);
75
+ }
76
+
77
+ /** Opt a game's own menus into gamepad navigation. */
78
+ setAutoNavigate(query: () => NavTarget[], back: (() => void) | null): void {
79
+ this.autoNavigateQuery = query;
80
+ this.autoNavigateBack = back;
81
+ this.autoNavigateState = null;
82
+ }
83
+
84
+ /** Make a CSS selector's visible matches the auto-navigate targets. */
85
+ static queryFor(selector: string | (() => NavTarget[])): () => NavTarget[] {
86
+ return typeof selector === 'string' ? () => visibleMatches(selector) : selector;
87
+ }
88
+
89
+ /** Stop auto-navigating. */
90
+ clearAutoNavigate(highlighter: NavHighlighter | null): void {
91
+ this.autoNavigateQuery = null;
92
+ this.autoNavigateState?.blur(highlighter);
93
+ this.autoNavigateState = null;
94
+ }
95
+
96
+ /** The hub menu is open: drive it with the highlight ring (gamepad only). */
97
+ driveMenu(menu: MenuBridge, isGamepadActive: boolean, context: NavigatorContext, highlighter: NavHighlighter | null): void {
98
+ if (!isGamepadActive) {
99
+ highlighter?.hideHighlight();
100
+ return;
101
+ }
102
+ this.menuState ??= new NavState(() => menuFocusables(menu.root));
103
+ this.driveList(this.menuState, 'm', context, () => {
104
+ menu.close();
105
+ });
106
+ }
107
+
108
+ /** The hub menu closed: drop its highlight. */
109
+ leaveMenu(highlighter: NavHighlighter | null): void {
110
+ this.menuState?.blur(highlighter);
111
+ this.menuState = null;
112
+ }
113
+
114
+ /** A game's own menus, when opted in, visible, and a gamepad is in use. */
115
+ driveAutoNavigate(isGamepadActive: boolean, context: NavigatorContext, highlighter: NavHighlighter | null): void {
116
+ const query = this.autoNavigateQuery;
117
+ if (query === null || !isGamepadActive || query().length === 0) {
118
+ this.autoNavigateState?.blur(highlighter);
119
+ this.autoNavigateState = null;
120
+ return;
121
+ }
122
+ this.autoNavigateState ??= new NavState(query);
123
+ this.driveList(this.autoNavigateState, 'an', context, this.autoNavigateBack);
124
+ }
125
+
126
+ /** The declared navigable groups that are enabled. */
127
+ driveGroups(isGroupEnabled: (group: string) => boolean, context: NavigatorContext): void {
128
+ for (const [group, { definition, state }] of this.groups) {
129
+ if (!isGroupEnabled(group)) {
130
+ continue;
131
+ }
132
+ const highlighter = context.highlighter();
133
+ const fires = (prefix: string, inputName: string | undefined): boolean => inputName !== undefined && context.repeats.fires(prefix + group, context.isPressed(inputName));
134
+ const shouldStepPrevious = fires('np:', definition.prev);
135
+ const shouldStepNext = fires('nn:', definition.next);
136
+ const shouldMoveUp = fires('nu:', definition.up);
137
+ const shouldMoveDown = fires('nd:', definition.down);
138
+ if (shouldStepNext) {
139
+ state.step(1, highlighter, context.listeners);
140
+ } else if (shouldStepPrevious) {
141
+ state.step(-1, highlighter, context.listeners);
142
+ } else if (shouldMoveUp) {
143
+ state.moveInDirection('up', highlighter, context.listeners);
144
+ } else if (shouldMoveDown) {
145
+ state.moveInDirection('down', highlighter, context.listeners);
146
+ }
147
+ if (definition.act !== '' && context.wentDown(definition.act)) {
148
+ state.act(highlighter, context.listeners);
149
+ }
150
+ state.position(highlighter);
151
+ }
152
+ }
153
+
154
+ /** Move with the stick or d-pad, A acts, B goes back. */
155
+ private driveList(state: NavState, keyPrefix: string, context: NavigatorContext, back: (() => void) | null): void {
156
+ const highlighter = context.highlighter();
157
+ state.requery(highlighter, context.listeners);
158
+ const held = anyPadDirections(context.gamepads, AXIS_DEADZONE);
159
+ const direction = DIRECTIONS.find((candidate) => context.repeats.fires(`${keyPrefix}:${candidate[0] ?? ''}`, held[candidate]));
160
+ if (direction !== undefined) {
161
+ state.moveInDirection(direction, highlighter, context.listeners);
162
+ }
163
+ if (context.presses.rose('act', isAnyPadButtonPressed(context.gamepads, PAD_BUTTON.A))) {
164
+ state.act(highlighter, context.listeners);
165
+ }
166
+ if (context.presses.rose('back', isAnyPadButtonPressed(context.gamepads, PAD_BUTTON.B))) {
167
+ back?.();
168
+ }
169
+ state.position(highlighter);
170
+ }
171
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The input system's side of the overlay: creating it on demand, building
3
+ * the on-screen controls, and deciding when they show (the player's choice,
4
+ * else the game's default, else on touch devices without a gamepad; editing
5
+ * the layout shows them all).
6
+ */
7
+ import { Overlay } from '../overlay/overlay';
8
+ import type { VirtualControlCallbacks } from '../overlay/virtual-controls';
9
+ import type { DeviceState } from './device-state';
10
+ import type { InputConfig, InputOverrides } from './types';
11
+
12
+ /** Whether this page counts as a touch device. */
13
+ export function detectTouchDevice(): boolean {
14
+ return typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || 'ontouchstart' in window);
15
+ }
16
+
17
+ /** On-screen controls write into the device state; touching one makes touch the active source. */
18
+ export function feedDeviceState(devices: DeviceState, reactions: { onTouch(): void; onDragged: VirtualControlCallbacks['onDragged'] }): VirtualControlCallbacks {
19
+ return {
20
+ onButtonDown: (id) => {
21
+ devices.pressedVirtualButtons.add(id);
22
+ reactions.onTouch();
23
+ },
24
+ onButtonUp: (id) => {
25
+ devices.pressedVirtualButtons.delete(id);
26
+ },
27
+ onStick: (id, outX, outY) => {
28
+ devices.sticks.set(id, { outX, outY });
29
+ reactions.onTouch();
30
+ },
31
+ onTap: () => {
32
+ devices.pulse('tap');
33
+ reactions.onTouch();
34
+ },
35
+ onDragged: reactions.onDragged,
36
+ };
37
+ }
38
+
39
+ /** The overlay and the on-screen controls' visibility. */
40
+ export class OnScreenControls {
41
+ /** Whether this page counts as a touch device. */
42
+ readonly isTouchDevice: boolean = detectTouchDevice();
43
+ /** Whether a gamepad is connected (controls hide by default then). */
44
+ isGamepadConnected = false;
45
+ /** The player's explicit show/hide choice, or `null`. */
46
+ playerChoice: boolean | null = null;
47
+ private overlayInstance: Overlay | null = null;
48
+ private gameDefault: boolean | null = null;
49
+ private isEditing = false;
50
+ private readonly enabledGroups: () => ReadonlySet<string>;
51
+
52
+ constructor(enabledGroups: () => ReadonlySet<string>) {
53
+ this.enabledGroups = enabledGroups;
54
+ }
55
+
56
+ /** The overlay if it exists yet. */
57
+ get existingOverlay(): Overlay | null {
58
+ return this.overlayInstance;
59
+ }
60
+
61
+ /** The overlay, created on first use. */
62
+ overlay(): Overlay {
63
+ this.overlayInstance ??= new Overlay();
64
+ return this.overlayInstance;
65
+ }
66
+
67
+ /** What the "Show on-screen controls" toggle reflects (excludes the editing force). */
68
+ get isShowing(): boolean {
69
+ return this.playerChoice ?? this.gameDefault ?? (this.isTouchDevice && !this.isGamepadConnected);
70
+ }
71
+
72
+ /** Set the game's default visibility (`null` = automatic). */
73
+ setGameDefault(isVisible: boolean | null): void {
74
+ this.gameDefault = isVisible;
75
+ this.sync();
76
+ }
77
+
78
+ /** Force every control visible while the player edits the layout. */
79
+ setEditing(isEditing: boolean): void {
80
+ this.isEditing = isEditing;
81
+ this.sync();
82
+ }
83
+
84
+ /** (Re)build the controls from the config and overrides. */
85
+ build(config: InputConfig, overrides: InputOverrides, callbacks: VirtualControlCallbacks): void {
86
+ this.overlay().build(config, overrides, callbacks);
87
+ this.sync();
88
+ }
89
+
90
+ /** Apply the visibility rules to the built controls. */
91
+ sync(): void {
92
+ if (this.overlayInstance === null) {
93
+ return;
94
+ }
95
+ if (this.isEditing) {
96
+ this.overlayInstance.setVirtualVisible(true, null);
97
+ } else {
98
+ this.overlayInstance.setVirtualVisible(this.isShowing, this.enabledGroups());
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The small, stable slice of the input system that other bundles on the page
3
+ * use (plan §4.4): the host menu (attach itself, `game.controls`, the
4
+ * Controls button), the social runtime's dialogs (navigate them), the
5
+ * overlay stack (suspend `play`), and a handover between two client copies.
6
+ * Every client version keeps these methods.
7
+ */
8
+ import { hasMethods } from '../realtime/cross-bundle';
9
+ import type { MenuBridge, NavTarget } from './types';
10
+
11
+ /** The cross-bundle input surface. */
12
+ export interface PageInputControl {
13
+ /** Whether the game declared its inputs. */
14
+ readonly isDefined: boolean;
15
+ /** The host menu attaches itself. */
16
+ attachMenu(bridge: MenuBridge | null): void;
17
+ /** The host menu passes `game.controls`. */
18
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page, parsed by the implementation
19
+ setLegacyControls(controls: unknown): void;
20
+ /** The menu's Controls button. */
21
+ openControls(): void;
22
+ /** Whether a group is on. */
23
+ isEnabled(group: string): boolean;
24
+ /** Turn a group on. */
25
+ enable(group: string): void;
26
+ /** Turn a group off. */
27
+ disable(group: string): void;
28
+ /** Make elements gamepad-navigable (the runtime's dialogs). */
29
+ autoNavigate(selector: string | (() => NavTarget[]), options?: { back?: () => void }): void;
30
+ /** Stop navigating them. */
31
+ clearAutoNavigate(): void;
32
+ /** Stop driving the page and hand over to `owner`. Absent on v1 copies. */
33
+ yieldPageTo?(owner: PageInputControl): void;
34
+ }
35
+
36
+ /** The methods every input system on the page has, v1 copies included. */
37
+ const PAGE_INPUT_METHODS = ['attachMenu', 'setLegacyControls', 'openControls', 'isEnabled', 'enable', 'disable', 'autoNavigate', 'clearAutoNavigate', 'define', 'value'] as const;
38
+
39
+ /** Whether `candidate` is some bundle's input system. */
40
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
41
+ export function isPageInputControl(candidate: unknown): candidate is PageInputControl {
42
+ return hasMethods(candidate, PAGE_INPUT_METHODS);
43
+ }