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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -21
  3. package/README.md +191 -139
  4. package/dist/doctor/cli.mjs +49 -19
  5. package/dist/doctor/cli.mjs.map +1 -1
  6. package/dist/shared/client-version.gen.js +1 -1
  7. package/dist/shared/daily.js +1 -1
  8. package/dist/shared/globals.js +8 -3
  9. package/dist/shared/globals.js.map +1 -1
  10. package/dist/shared/index.d.ts +1 -1
  11. package/package.json +89 -89
  12. package/src/auto.ts +39 -39
  13. package/src/core/connection.ts +299 -299
  14. package/src/core/errors.ts +147 -147
  15. package/src/core/game-data.ts +38 -38
  16. package/src/core/hub-economy.ts +179 -179
  17. package/src/core/hub-progress.ts +205 -205
  18. package/src/core/hub.ts +131 -131
  19. package/src/core/menu-control.ts +61 -61
  20. package/src/core/offline/events.ts +50 -50
  21. package/src/core/offline/favorites.ts +21 -21
  22. package/src/core/offline/local-store.ts +261 -261
  23. package/src/core/offline/queue.ts +171 -171
  24. package/src/core/page.ts +74 -74
  25. package/src/core/rest/endpoint-types.ts +22 -22
  26. package/src/core/rest/rest-client.ts +152 -152
  27. package/src/core/storage/json-guards.ts +74 -74
  28. package/src/core/storage/key-value.ts +66 -66
  29. package/src/core/ticket.ts +43 -43
  30. package/src/core/types.ts +134 -134
  31. package/src/core/version/client-version.gen.ts +1 -1
  32. package/src/core/version/hub-version.ts +62 -62
  33. package/src/core/version/semver.ts +97 -97
  34. package/src/daily/daily-system.ts +161 -161
  35. package/src/daily/page-daily.ts +36 -36
  36. package/src/daily/types.ts +67 -67
  37. package/src/daily.ts +10 -10
  38. package/src/doctor/checks.ts +78 -76
  39. package/src/doctor/cli.ts +98 -106
  40. package/src/doctor/hub-version-fetch.ts +43 -0
  41. package/src/doctor/tsconfig.json +12 -12
  42. package/src/globals.ts +115 -110
  43. package/src/index.ts +50 -50
  44. package/src/input/device-state.ts +146 -146
  45. package/src/input/edges.ts +55 -55
  46. package/src/input/gamepad.ts +61 -61
  47. package/src/input/input-events.ts +32 -32
  48. package/src/input/input-registry.ts +164 -164
  49. package/src/input/input-system.ts +393 -393
  50. package/src/input/legacy-controls.ts +64 -64
  51. package/src/input/nav-state.ts +191 -191
  52. package/src/input/navigator.ts +171 -171
  53. package/src/input/on-screen-controls.ts +101 -101
  54. package/src/input/page-control.ts +43 -43
  55. package/src/input/page-input.ts +193 -193
  56. package/src/input/player-remaps.ts +77 -77
  57. package/src/input/source-values.ts +116 -116
  58. package/src/input/spatial.ts +47 -47
  59. package/src/input/types.ts +331 -331
  60. package/src/input/zero-config.ts +177 -177
  61. package/src/input.ts +22 -22
  62. package/src/mp.ts +10 -10
  63. package/src/overlay/controls-panel.ts +163 -163
  64. package/src/overlay/dom.ts +46 -46
  65. package/src/overlay/layout-clusters.ts +162 -162
  66. package/src/overlay/layout-geometry.ts +136 -136
  67. package/src/overlay/layout.ts +203 -203
  68. package/src/overlay/listener-scope.ts +23 -23
  69. package/src/overlay/on-screen-keyboard.ts +137 -137
  70. package/src/overlay/overlay.ts +208 -208
  71. package/src/overlay/styles.ts +9 -9
  72. package/src/overlay/virtual-controls.ts +262 -262
  73. package/src/race.ts +10 -10
  74. package/src/realtime/bridge.ts +121 -121
  75. package/src/realtime/cross-bundle.ts +29 -29
  76. package/src/realtime/fallback-notice.ts +48 -48
  77. package/src/realtime/hub-link.ts +65 -65
  78. package/src/realtime/latest-state.ts +33 -33
  79. package/src/realtime/multiplayer-forwarder.ts +28 -28
  80. package/src/realtime/multiplayer-types.ts +52 -52
  81. package/src/realtime/page-runtime.ts +60 -60
  82. package/src/realtime/race-forwarder.ts +59 -59
  83. package/src/realtime/race-types.ts +132 -132
  84. package/src/realtime/room-types.ts +125 -125
  85. package/src/realtime/rooms-forwarder.ts +79 -79
  86. package/src/realtime/runtime-api.ts +49 -49
  87. package/src/realtime/runtime-opener.ts +61 -61
  88. package/src/realtime/social-forwarder.ts +72 -72
  89. package/src/realtime/social-types.ts +78 -78
  90. package/src/realtime/types.ts +53 -53
  91. package/src/realtime/wire-types.ts +72 -72
  92. package/src/rooms.ts +10 -10
  93. package/src/social.ts +18 -18
  94. package/src/types.ts +13 -13
  95. package/src/uistack/page-stack.ts +45 -45
  96. package/src/uistack/play-suspension.ts +82 -82
  97. package/src/uistack/types.ts +76 -76
  98. package/src/uistack/ui-stack.ts +144 -144
@@ -1,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
+ }