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

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 (154) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -21
  3. package/README.md +206 -139
  4. package/dist/auto.d.ts +6 -6
  5. package/dist/auto.js +2 -2
  6. package/dist/doctor/cli.mjs +62 -25
  7. package/dist/doctor/cli.mjs.map +1 -1
  8. package/dist/index.d.ts +6 -6
  9. package/dist/index.js +2 -2
  10. package/dist/mp.d.ts +3 -3
  11. package/dist/race.d.ts +1 -1
  12. package/dist/rooms.d.ts +3 -3
  13. package/dist/shared/bridge.js +9 -3
  14. package/dist/shared/bridge.js.map +1 -1
  15. package/dist/shared/client-version.gen.js +1 -1
  16. package/dist/shared/daily.d.ts +10 -4
  17. package/dist/shared/daily.d.ts.map +1 -1
  18. package/dist/shared/daily.js +12 -2
  19. package/dist/shared/daily.js.map +1 -1
  20. package/dist/shared/globals.js +8 -3
  21. package/dist/shared/globals.js.map +1 -1
  22. package/dist/shared/index.d.ts +314 -35
  23. package/dist/shared/index.d.ts.map +1 -1
  24. package/dist/shared/input.js +7 -2
  25. package/dist/shared/input.js.map +1 -1
  26. package/dist/shared/latest-state.js +1 -1
  27. package/dist/shared/mp.js +10 -2
  28. package/dist/shared/mp.js.map +1 -1
  29. package/dist/shared/multiplayer-types.d.ts +15 -2
  30. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  31. package/dist/shared/race-types.d.ts +1 -1
  32. package/dist/shared/race.js +1 -1
  33. package/dist/shared/room-types.d.ts +28 -3
  34. package/dist/shared/room-types.d.ts.map +1 -1
  35. package/dist/shared/rooms.d.ts.map +1 -1
  36. package/dist/shared/rooms.js +10 -2
  37. package/dist/shared/rooms.js.map +1 -1
  38. package/dist/shared/runtime-opener.js +1 -1
  39. package/dist/shared/social.d.ts +23 -8
  40. package/dist/shared/social.d.ts.map +1 -1
  41. package/dist/shared/social.js +6 -1
  42. package/dist/shared/social.js.map +1 -1
  43. package/dist/shared/src.js +1588 -388
  44. package/dist/shared/src.js.map +1 -1
  45. package/dist/shared/wire-types.d.ts +6 -2
  46. package/dist/shared/wire-types.d.ts.map +1 -1
  47. package/dist/social.d.ts +5 -5
  48. package/dist/social.js +1 -1
  49. package/package.json +89 -89
  50. package/src/auto.ts +39 -39
  51. package/src/core/connection.ts +394 -299
  52. package/src/core/errors.ts +147 -147
  53. package/src/core/game-data.ts +40 -38
  54. package/src/core/hub-economy.ts +216 -179
  55. package/src/core/hub-progress.ts +202 -205
  56. package/src/core/hub.ts +133 -131
  57. package/src/core/menu-control.ts +61 -61
  58. package/src/core/offline/events.ts +50 -50
  59. package/src/core/offline/favorites.ts +21 -21
  60. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  61. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  62. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  63. package/src/core/offline/hub-kinds/index.ts +35 -0
  64. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  65. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  66. package/src/core/offline/kind-adapters.ts +48 -0
  67. package/src/core/offline/local-inventory.ts +64 -0
  68. package/src/core/offline/local-store.ts +259 -261
  69. package/src/core/offline/offline-api.ts +65 -0
  70. package/src/core/offline/outbox-entry.ts +59 -0
  71. package/src/core/offline/outbox-kinds.ts +78 -0
  72. package/src/core/offline/outbox-merge.ts +39 -0
  73. package/src/core/offline/outbox-replayer.ts +141 -0
  74. package/src/core/offline/outbox-store.ts +85 -0
  75. package/src/core/offline/outbox.ts +232 -0
  76. package/src/core/offline/queue.ts +126 -171
  77. package/src/core/offline/replay-lease.ts +87 -0
  78. package/src/core/offline/replay-policy.ts +52 -0
  79. package/src/core/page.ts +74 -74
  80. package/src/core/rest/endpoint-types.ts +22 -22
  81. package/src/core/rest/rest-client.ts +163 -152
  82. package/src/core/rest/routes.gen.ts +108 -87
  83. package/src/core/storage/json-guards.ts +80 -74
  84. package/src/core/storage/key-value.ts +66 -66
  85. package/src/core/ticket.ts +43 -43
  86. package/src/core/types.ts +134 -134
  87. package/src/core/version/client-version.gen.ts +1 -1
  88. package/src/core/version/hub-version.ts +62 -62
  89. package/src/core/version/semver.ts +97 -97
  90. package/src/daily/daily-system.ts +168 -161
  91. package/src/daily/page-daily.ts +36 -36
  92. package/src/daily/types.ts +68 -67
  93. package/src/daily.ts +10 -10
  94. package/src/doctor/checks.ts +86 -76
  95. package/src/doctor/cli.ts +98 -106
  96. package/src/doctor/hub-version-fetch.ts +43 -0
  97. package/src/doctor/tsconfig.json +12 -12
  98. package/src/globals.ts +115 -110
  99. package/src/index.ts +55 -50
  100. package/src/input/device-state.ts +146 -146
  101. package/src/input/edges.ts +55 -55
  102. package/src/input/gamepad.ts +61 -61
  103. package/src/input/input-events.ts +32 -32
  104. package/src/input/input-registry.ts +171 -164
  105. package/src/input/input-system.ts +393 -393
  106. package/src/input/legacy-controls.ts +64 -64
  107. package/src/input/nav-state.ts +191 -191
  108. package/src/input/navigator.ts +171 -171
  109. package/src/input/on-screen-controls.ts +101 -101
  110. package/src/input/page-control.ts +43 -43
  111. package/src/input/page-input.ts +193 -193
  112. package/src/input/player-remaps.ts +77 -77
  113. package/src/input/source-values.ts +116 -116
  114. package/src/input/spatial.ts +47 -47
  115. package/src/input/types.ts +331 -331
  116. package/src/input/zero-config.ts +177 -177
  117. package/src/input.ts +22 -22
  118. package/src/mp.ts +10 -10
  119. package/src/overlay/controls-panel.ts +163 -163
  120. package/src/overlay/dom.ts +46 -46
  121. package/src/overlay/layout-clusters.ts +162 -162
  122. package/src/overlay/layout-geometry.ts +136 -136
  123. package/src/overlay/layout.ts +203 -203
  124. package/src/overlay/listener-scope.ts +23 -23
  125. package/src/overlay/on-screen-keyboard.ts +137 -137
  126. package/src/overlay/overlay.ts +208 -208
  127. package/src/overlay/styles.ts +9 -9
  128. package/src/overlay/virtual-controls.ts +262 -262
  129. package/src/race.ts +10 -10
  130. package/src/realtime/bridge.ts +126 -121
  131. package/src/realtime/cross-bundle.ts +29 -29
  132. package/src/realtime/fallback-notice.ts +48 -48
  133. package/src/realtime/hub-link.ts +65 -65
  134. package/src/realtime/latest-state.ts +33 -33
  135. package/src/realtime/multiplayer-forwarder.ts +40 -28
  136. package/src/realtime/multiplayer-types.ts +66 -52
  137. package/src/realtime/page-runtime.ts +60 -60
  138. package/src/realtime/race-forwarder.ts +59 -59
  139. package/src/realtime/race-types.ts +132 -132
  140. package/src/realtime/room-types.ts +151 -125
  141. package/src/realtime/rooms-forwarder.ts +91 -79
  142. package/src/realtime/runtime-api.ts +60 -49
  143. package/src/realtime/runtime-opener.ts +61 -61
  144. package/src/realtime/social-forwarder.ts +77 -72
  145. package/src/realtime/social-types.ts +86 -78
  146. package/src/realtime/types.ts +53 -53
  147. package/src/realtime/wire-types.ts +79 -72
  148. package/src/rooms.ts +10 -10
  149. package/src/social.ts +18 -18
  150. package/src/types.ts +13 -13
  151. package/src/uistack/page-stack.ts +45 -45
  152. package/src/uistack/play-suspension.ts +82 -82
  153. package/src/uistack/types.ts +76 -76
  154. package/src/uistack/ui-stack.ts +144 -144
@@ -1,203 +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
- }
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
+ }
@@ -1,23 +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
- }
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
+ }