@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,162 +1,162 @@
1
- /**
2
- * The two ways a corner's buttons are arranged: the classic columns beside
3
- * its sticks (or a centred row for top/bottom anchors), and the compact rows
4
- * past the sticks for narrow screens. Pure: no DOM.
5
- */
6
- import type { AnchorSides, Area, LayoutBox, LayoutItem } from './layout-geometry';
7
-
8
- /** The viewport and spacing one layout run works in. */
9
- export interface ClusterContext {
10
- readonly area: Area;
11
- readonly gap: number;
12
- readonly viewportWidth: number;
13
- readonly viewportHeight: number;
14
- }
15
-
16
- /** A corner's sticks: their combined box and the width they take from the edge. */
17
- export interface StickSpan {
18
- readonly span: LayoutBox | null;
19
- readonly reserve: number;
20
- }
21
-
22
- /** A new column starts after this many buttons. */
23
- const MAX_COLUMN_LENGTH = 3;
24
-
25
- /** Wrap `items` into rows no wider than `maxWidth`. */
26
- function wrapIntoRows(items: readonly LayoutItem[], maxWidth: number, gap: number): LayoutItem[][] {
27
- const rows: LayoutItem[][] = [];
28
- let row: LayoutItem[] = [];
29
- let rowWidth = 0;
30
- for (const item of items) {
31
- const widened = row.length > 0 ? rowWidth + gap + item.w : item.w;
32
- if (row.length > 0 && widened > maxWidth) {
33
- rows.push(row);
34
- row = [item];
35
- rowWidth = item.w;
36
- } else {
37
- row.push(item);
38
- rowWidth = widened;
39
- }
40
- }
41
- if (row.length > 0) {
42
- rows.push(row);
43
- }
44
- return rows;
45
- }
46
-
47
- /** A row's total width and its tallest item. */
48
- function measureRow(row: readonly LayoutItem[], gap: number): { width: number; height: number } {
49
- return {
50
- width: row.reduce((total, item) => total + item.w, 0) + gap * (row.length - 1),
51
- height: Math.max(...row.map((item) => item.h)),
52
- };
53
- }
54
-
55
- /** Centred rows along the top or bottom edge (past a stick sharing the anchor). */
56
- function centredRows(buttons: readonly LayoutItem[], sides: AnchorSides, sticks: StickSpan, context: ClusterContext, into: Map<string, LayoutBox>): void {
57
- const { area, gap, viewportWidth, viewportHeight } = context;
58
- const rows = wrapIntoRows(buttons, area.right - area.left, gap);
59
- const sizes = rows.map((row) => measureRow(row, gap));
60
- const totalHeight = sizes.reduce((total, size) => total + size.height, 0) + gap * (rows.length - 1);
61
- let along = sticks.span === null ? 0 : sticks.span.h + gap;
62
- rows.forEach((row, rowIndex) => {
63
- const size = sizes[rowIndex] ?? { width: 0, height: 0 };
64
- let left = (viewportWidth - size.width) / 2;
65
- for (const item of row) {
66
- const nudgeX = item.offset?.x ?? 0;
67
- const nudgeY = item.offset?.y ?? 0;
68
- const centreY = sides.vertical === 'top' ? area.top + along + size.height / 2 + nudgeY
69
- : sides.vertical === 'bottom' ? area.bottom - along - size.height / 2 - nudgeY
70
- : (viewportHeight - totalHeight) / 2 + along + size.height / 2 + nudgeY;
71
- into.set(item.id, { x: left + nudgeX, y: centreY - item.h / 2, w: item.w, h: item.h });
72
- left += item.w + gap;
73
- }
74
- along += size.height + gap;
75
- });
76
- }
77
-
78
- /** One button's cell in the columns. */
79
- interface ColumnCell {
80
- readonly item: LayoutItem;
81
- readonly inset: number;
82
- readonly along: number;
83
- readonly column: number;
84
- }
85
-
86
- /**
87
- * The classic layout: columns inset past any stick, stacked along the
88
- * vertical edge (a new inward column after three, or when too tall), or
89
- * centred rows for top and bottom anchors.
90
- */
91
- export function clusterBesideSticks(buttons: readonly LayoutItem[], sides: AnchorSides, sticks: StickSpan, context: ClusterContext, into: Map<string, LayoutBox>): void {
92
- if (sides.horizontal === 'mid') {
93
- centredRows(buttons, sides, sticks, context, into);
94
- return;
95
- }
96
- const { area, gap, viewportHeight } = context;
97
- const availableHeight = area.bottom - area.top;
98
- let inset = sticks.span === null ? 0 : sticks.reserve;
99
- let along = 0;
100
- let columnWidth = 0;
101
- let countInColumn = 0;
102
- let column = 0;
103
- const cells: ColumnCell[] = [];
104
- const columnHeights: number[] = [];
105
- for (const item of buttons) {
106
- if (countInColumn > 0 && (countInColumn >= MAX_COLUMN_LENGTH || along + item.h > availableHeight)) {
107
- inset += columnWidth + gap;
108
- along = 0;
109
- columnWidth = 0;
110
- countInColumn = 0;
111
- column += 1;
112
- }
113
- cells.push({ item, inset, along, column });
114
- columnHeights[column] = along + item.h;
115
- along += item.h + gap;
116
- columnWidth = Math.max(columnWidth, item.w);
117
- countInColumn += 1;
118
- }
119
- for (const cell of cells) {
120
- const { item } = cell;
121
- const nudgeX = item.offset?.x ?? 0;
122
- const nudgeY = item.offset?.y ?? 0;
123
- const left = sides.horizontal === 'left' ? area.left + cell.inset + nudgeX : area.right - cell.inset - item.w - nudgeX;
124
- const top = sides.vertical === 'top' ? area.top + cell.along + nudgeY
125
- : sides.vertical === 'bottom' ? area.bottom - cell.along - item.h - nudgeY
126
- : (viewportHeight - (columnHeights[cell.column] ?? 0)) / 2 + cell.along + nudgeY;
127
- into.set(item.id, { x: left, y: top, w: item.w, h: item.h });
128
- }
129
- }
130
-
131
- /**
132
- * The compact layout: rows just past the corner's sticks (above them for
133
- * bottom corners), first-declared button innermost, each row at most half
134
- * the screen wide.
135
- */
136
- export function rowsBeyondSticks(buttons: readonly LayoutItem[], sides: AnchorSides, span: LayoutBox, context: ClusterContext): Map<string, LayoutBox> {
137
- const { area, gap, viewportWidth } = context;
138
- const placed = new Map<string, LayoutBox>();
139
- const isLeft = sides.horizontal === 'left';
140
- const maxWidth = isLeft ? viewportWidth / 2 - gap / 2 - area.left : area.right - (viewportWidth / 2 + gap / 2);
141
- let along = 0;
142
- for (const row of wrapIntoRows(buttons, maxWidth, gap)) {
143
- const size = measureRow(row, gap);
144
- // Flush with the sticks' inner edge when the row fits over them, else from the outer edge inward.
145
- const doesRowFitOverSticks = size.width <= span.w;
146
- let left = isLeft
147
- ? (doesRowFitOverSticks ? span.x + span.w - size.width : area.left)
148
- : (doesRowFitOverSticks ? span.x : area.right - size.width);
149
- const innermostFirst = isLeft ? [...row].reverse() : row;
150
- for (const item of innermostFirst) {
151
- const nudgeX = item.offset?.x ?? 0;
152
- const nudgeY = item.offset?.y ?? 0;
153
- const centreY = sides.vertical === 'bottom'
154
- ? span.y - gap - along - size.height / 2 - nudgeY
155
- : span.y + span.h + gap + along + size.height / 2 + nudgeY;
156
- placed.set(item.id, { x: left + (isLeft ? nudgeX : -nudgeX), y: centreY - item.h / 2, w: item.w, h: item.h });
157
- left += item.w + gap;
158
- }
159
- along += size.height + gap;
160
- }
161
- return placed;
162
- }
1
+ /**
2
+ * The two ways a corner's buttons are arranged: the classic columns beside
3
+ * its sticks (or a centred row for top/bottom anchors), and the compact rows
4
+ * past the sticks for narrow screens. Pure: no DOM.
5
+ */
6
+ import type { AnchorSides, Area, LayoutBox, LayoutItem } from './layout-geometry';
7
+
8
+ /** The viewport and spacing one layout run works in. */
9
+ export interface ClusterContext {
10
+ readonly area: Area;
11
+ readonly gap: number;
12
+ readonly viewportWidth: number;
13
+ readonly viewportHeight: number;
14
+ }
15
+
16
+ /** A corner's sticks: their combined box and the width they take from the edge. */
17
+ export interface StickSpan {
18
+ readonly span: LayoutBox | null;
19
+ readonly reserve: number;
20
+ }
21
+
22
+ /** A new column starts after this many buttons. */
23
+ const MAX_COLUMN_LENGTH = 3;
24
+
25
+ /** Wrap `items` into rows no wider than `maxWidth`. */
26
+ function wrapIntoRows(items: readonly LayoutItem[], maxWidth: number, gap: number): LayoutItem[][] {
27
+ const rows: LayoutItem[][] = [];
28
+ let row: LayoutItem[] = [];
29
+ let rowWidth = 0;
30
+ for (const item of items) {
31
+ const widened = row.length > 0 ? rowWidth + gap + item.w : item.w;
32
+ if (row.length > 0 && widened > maxWidth) {
33
+ rows.push(row);
34
+ row = [item];
35
+ rowWidth = item.w;
36
+ } else {
37
+ row.push(item);
38
+ rowWidth = widened;
39
+ }
40
+ }
41
+ if (row.length > 0) {
42
+ rows.push(row);
43
+ }
44
+ return rows;
45
+ }
46
+
47
+ /** A row's total width and its tallest item. */
48
+ function measureRow(row: readonly LayoutItem[], gap: number): { width: number; height: number } {
49
+ return {
50
+ width: row.reduce((total, item) => total + item.w, 0) + gap * (row.length - 1),
51
+ height: Math.max(...row.map((item) => item.h)),
52
+ };
53
+ }
54
+
55
+ /** Centred rows along the top or bottom edge (past a stick sharing the anchor). */
56
+ function centredRows(buttons: readonly LayoutItem[], sides: AnchorSides, sticks: StickSpan, context: ClusterContext, into: Map<string, LayoutBox>): void {
57
+ const { area, gap, viewportWidth, viewportHeight } = context;
58
+ const rows = wrapIntoRows(buttons, area.right - area.left, gap);
59
+ const sizes = rows.map((row) => measureRow(row, gap));
60
+ const totalHeight = sizes.reduce((total, size) => total + size.height, 0) + gap * (rows.length - 1);
61
+ let along = sticks.span === null ? 0 : sticks.span.h + gap;
62
+ rows.forEach((row, rowIndex) => {
63
+ const size = sizes[rowIndex] ?? { width: 0, height: 0 };
64
+ let left = (viewportWidth - size.width) / 2;
65
+ for (const item of row) {
66
+ const nudgeX = item.offset?.x ?? 0;
67
+ const nudgeY = item.offset?.y ?? 0;
68
+ const centreY = sides.vertical === 'top' ? area.top + along + size.height / 2 + nudgeY
69
+ : sides.vertical === 'bottom' ? area.bottom - along - size.height / 2 - nudgeY
70
+ : (viewportHeight - totalHeight) / 2 + along + size.height / 2 + nudgeY;
71
+ into.set(item.id, { x: left + nudgeX, y: centreY - item.h / 2, w: item.w, h: item.h });
72
+ left += item.w + gap;
73
+ }
74
+ along += size.height + gap;
75
+ });
76
+ }
77
+
78
+ /** One button's cell in the columns. */
79
+ interface ColumnCell {
80
+ readonly item: LayoutItem;
81
+ readonly inset: number;
82
+ readonly along: number;
83
+ readonly column: number;
84
+ }
85
+
86
+ /**
87
+ * The classic layout: columns inset past any stick, stacked along the
88
+ * vertical edge (a new inward column after three, or when too tall), or
89
+ * centred rows for top and bottom anchors.
90
+ */
91
+ export function clusterBesideSticks(buttons: readonly LayoutItem[], sides: AnchorSides, sticks: StickSpan, context: ClusterContext, into: Map<string, LayoutBox>): void {
92
+ if (sides.horizontal === 'mid') {
93
+ centredRows(buttons, sides, sticks, context, into);
94
+ return;
95
+ }
96
+ const { area, gap, viewportHeight } = context;
97
+ const availableHeight = area.bottom - area.top;
98
+ let inset = sticks.span === null ? 0 : sticks.reserve;
99
+ let along = 0;
100
+ let columnWidth = 0;
101
+ let countInColumn = 0;
102
+ let column = 0;
103
+ const cells: ColumnCell[] = [];
104
+ const columnHeights: number[] = [];
105
+ for (const item of buttons) {
106
+ if (countInColumn > 0 && (countInColumn >= MAX_COLUMN_LENGTH || along + item.h > availableHeight)) {
107
+ inset += columnWidth + gap;
108
+ along = 0;
109
+ columnWidth = 0;
110
+ countInColumn = 0;
111
+ column += 1;
112
+ }
113
+ cells.push({ item, inset, along, column });
114
+ columnHeights[column] = along + item.h;
115
+ along += item.h + gap;
116
+ columnWidth = Math.max(columnWidth, item.w);
117
+ countInColumn += 1;
118
+ }
119
+ for (const cell of cells) {
120
+ const { item } = cell;
121
+ const nudgeX = item.offset?.x ?? 0;
122
+ const nudgeY = item.offset?.y ?? 0;
123
+ const left = sides.horizontal === 'left' ? area.left + cell.inset + nudgeX : area.right - cell.inset - item.w - nudgeX;
124
+ const top = sides.vertical === 'top' ? area.top + cell.along + nudgeY
125
+ : sides.vertical === 'bottom' ? area.bottom - cell.along - item.h - nudgeY
126
+ : (viewportHeight - (columnHeights[cell.column] ?? 0)) / 2 + cell.along + nudgeY;
127
+ into.set(item.id, { x: left, y: top, w: item.w, h: item.h });
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The compact layout: rows just past the corner's sticks (above them for
133
+ * bottom corners), first-declared button innermost, each row at most half
134
+ * the screen wide.
135
+ */
136
+ export function rowsBeyondSticks(buttons: readonly LayoutItem[], sides: AnchorSides, span: LayoutBox, context: ClusterContext): Map<string, LayoutBox> {
137
+ const { area, gap, viewportWidth } = context;
138
+ const placed = new Map<string, LayoutBox>();
139
+ const isLeft = sides.horizontal === 'left';
140
+ const maxWidth = isLeft ? viewportWidth / 2 - gap / 2 - area.left : area.right - (viewportWidth / 2 + gap / 2);
141
+ let along = 0;
142
+ for (const row of wrapIntoRows(buttons, maxWidth, gap)) {
143
+ const size = measureRow(row, gap);
144
+ // Flush with the sticks' inner edge when the row fits over them, else from the outer edge inward.
145
+ const doesRowFitOverSticks = size.width <= span.w;
146
+ let left = isLeft
147
+ ? (doesRowFitOverSticks ? span.x + span.w - size.width : area.left)
148
+ : (doesRowFitOverSticks ? span.x : area.right - size.width);
149
+ const innermostFirst = isLeft ? [...row].reverse() : row;
150
+ for (const item of innermostFirst) {
151
+ const nudgeX = item.offset?.x ?? 0;
152
+ const nudgeY = item.offset?.y ?? 0;
153
+ const centreY = sides.vertical === 'bottom'
154
+ ? span.y - gap - along - size.height / 2 - nudgeY
155
+ : span.y + span.h + gap + along + size.height / 2 + nudgeY;
156
+ placed.set(item.id, { x: left + (isLeft ? nudgeX : -nudgeX), y: centreY - item.h / 2, w: item.w, h: item.h });
157
+ left += item.w + gap;
158
+ }
159
+ along += size.height + gap;
160
+ }
161
+ return placed;
162
+ }
@@ -1,136 +1,136 @@
1
- /**
2
- * Geometry for the on-screen control layout: boxes, overlap tests, sizes and
3
- * the free-spot search. Pure: no DOM.
4
- */
5
- import type { Anchor, VirtualControlType, VirtualDef } from '../input/types';
6
-
7
- /** A placed control: top-left corner and size, in viewport px. */
8
- export interface LayoutBox {
9
- x: number;
10
- y: number;
11
- w: number;
12
- h: number;
13
- }
14
-
15
- /** One control as the layout engine sees it (its rendered px size). */
16
- export interface LayoutItem {
17
- /** The control id. */
18
- id: string;
19
- /** Its type. */
20
- type: VirtualDef['type'];
21
- /** Its anchor. */
22
- place: Anchor;
23
- /** Rendered width. */
24
- w: number;
25
- /** Rendered height. */
26
- h: number;
27
- /** An explicit position from the edges. */
28
- pos?: VirtualDef['pos'] | undefined;
29
- /** A nudge away from the anchor's edges. */
30
- offset?: VirtualDef['offset'] | undefined;
31
- /** The player's dragged spot: the control's top-left corner as % of the viewport. */
32
- override?: { x: number; y: number } | undefined;
33
- }
34
-
35
- /** Px insets from each viewport edge. */
36
- export interface Insets {
37
- top: number;
38
- right: number;
39
- bottom: number;
40
- left: number;
41
- }
42
-
43
- /** The rectangle auto-placed controls live in: `[left, right] × [top, bottom]`. */
44
- export interface Area {
45
- left: number;
46
- top: number;
47
- right: number;
48
- bottom: number;
49
- }
50
-
51
- /** Which edges an anchor hugs. */
52
- export interface AnchorSides {
53
- horizontal: 'left' | 'right' | 'mid';
54
- vertical: 'top' | 'bottom' | 'mid';
55
- }
56
-
57
- /** Buttons are this size by default; sticks and dpads are bigger. */
58
- const DEFAULT_BUTTON_SIZE = 72;
59
- const DEFAULT_STICK_SIZE = 130;
60
-
61
- /** Whether a control is a stick (joystick or dpad). */
62
- export function isStickType(type: VirtualControlType): boolean {
63
- return type === 'joystick' || type === 'dpad';
64
- }
65
-
66
- /** A control's declared size (before borders). */
67
- export function declaredSize(definition: Pick<VirtualDef, 'type' | 'size' | 'width' | 'height'>): { w: number; h: number } {
68
- const base = definition.size ?? (definition.type === 'button' ? DEFAULT_BUTTON_SIZE : DEFAULT_STICK_SIZE);
69
- return { w: definition.width ?? base, h: definition.height ?? base };
70
- }
71
-
72
- /** The rendered size of a control: buttons and taps draw a 2px border outside their declared size; sticks draw theirs inside. */
73
- export function controlSize(definition: Pick<VirtualDef, 'type' | 'size' | 'width' | 'height'>): { w: number; h: number } {
74
- const declared = declaredSize(definition);
75
- const border = isStickType(definition.type) ? 0 : 4;
76
- return { w: declared.w + border, h: declared.h + border };
77
- }
78
-
79
- /** Which edges an anchor hugs. */
80
- export function anchorSides(anchor: Anchor): AnchorSides {
81
- return {
82
- horizontal: anchor.endsWith('left') ? 'left' : anchor.endsWith('right') ? 'right' : 'mid',
83
- vertical: anchor.startsWith('top') ? 'top' : anchor.startsWith('bottom') ? 'bottom' : 'mid',
84
- };
85
- }
86
-
87
- /** Do two boxes overlap (or come closer than `padding` px)? */
88
- export function boxesTouch(first: LayoutBox, second: LayoutBox, padding = 0): boolean {
89
- return first.x < second.x + second.w + padding && second.x < first.x + first.w + padding
90
- && first.y < second.y + second.h + padding && second.y < first.y + first.h + padding;
91
- }
92
-
93
- /** Clamp `amount` into `[low, high]`. */
94
- export function clampBetween(amount: number, low: number, high: number): number {
95
- return Math.max(low, Math.min(high, amount));
96
- }
97
-
98
- /** The smallest box covering both. */
99
- export function unionBox(first: LayoutBox, second: LayoutBox): LayoutBox {
100
- const left = Math.min(first.x, second.x);
101
- const top = Math.min(first.y, second.y);
102
- return { x: left, y: top, w: Math.max(first.x + first.w, second.x + second.w) - left, h: Math.max(first.y + first.h, second.y + second.h) - top };
103
- }
104
-
105
- /** The box moved inside `area`. */
106
- export function clampBox(box: LayoutBox, area: Area): LayoutBox {
107
- return { ...box, x: clampBetween(box.x, area.left, area.right - box.w), y: clampBetween(box.y, area.top, area.bottom - box.h) };
108
- }
109
-
110
- /** Whether a box lies inside `area` (half a px of slack). */
111
- export function isInside(box: LayoutBox, area: Area): boolean {
112
- return box.x >= area.left - 0.5 && box.y >= area.top - 0.5 && box.x + box.w <= area.right + 0.5 && box.y + box.h <= area.bottom + 0.5;
113
- }
114
-
115
- /** Grid step of the free-spot search. */
116
- const SEARCH_STEP = 4;
117
-
118
- /** The free spot inside `area` closest to `wanted`, on a 4px grid, or `null`. */
119
- export function nearestFreeBox(wanted: LayoutBox, area: Area, isFree: (box: LayoutBox) => boolean): LayoutBox | null {
120
- let best: LayoutBox | null = null;
121
- let bestDistance = Infinity;
122
- for (let top = area.top; top <= area.bottom - wanted.h + 0.01; top += SEARCH_STEP) {
123
- for (let left = area.left; left <= area.right - wanted.w + 0.01; left += SEARCH_STEP) {
124
- const distance = (left - wanted.x) ** 2 + (top - wanted.y) ** 2;
125
- if (distance >= bestDistance) {
126
- continue;
127
- }
128
- const candidate = { x: left, y: top, w: wanted.w, h: wanted.h };
129
- if (isFree(candidate)) {
130
- best = candidate;
131
- bestDistance = distance;
132
- }
133
- }
134
- }
135
- return best;
136
- }
1
+ /**
2
+ * Geometry for the on-screen control layout: boxes, overlap tests, sizes and
3
+ * the free-spot search. Pure: no DOM.
4
+ */
5
+ import type { Anchor, VirtualControlType, VirtualDef } from '../input/types';
6
+
7
+ /** A placed control: top-left corner and size, in viewport px. */
8
+ export interface LayoutBox {
9
+ x: number;
10
+ y: number;
11
+ w: number;
12
+ h: number;
13
+ }
14
+
15
+ /** One control as the layout engine sees it (its rendered px size). */
16
+ export interface LayoutItem {
17
+ /** The control id. */
18
+ id: string;
19
+ /** Its type. */
20
+ type: VirtualDef['type'];
21
+ /** Its anchor. */
22
+ place: Anchor;
23
+ /** Rendered width. */
24
+ w: number;
25
+ /** Rendered height. */
26
+ h: number;
27
+ /** An explicit position from the edges. */
28
+ pos?: VirtualDef['pos'] | undefined;
29
+ /** A nudge away from the anchor's edges. */
30
+ offset?: VirtualDef['offset'] | undefined;
31
+ /** The player's dragged spot: the control's top-left corner as % of the viewport. */
32
+ override?: { x: number; y: number } | undefined;
33
+ }
34
+
35
+ /** Px insets from each viewport edge. */
36
+ export interface Insets {
37
+ top: number;
38
+ right: number;
39
+ bottom: number;
40
+ left: number;
41
+ }
42
+
43
+ /** The rectangle auto-placed controls live in: `[left, right] × [top, bottom]`. */
44
+ export interface Area {
45
+ left: number;
46
+ top: number;
47
+ right: number;
48
+ bottom: number;
49
+ }
50
+
51
+ /** Which edges an anchor hugs. */
52
+ export interface AnchorSides {
53
+ horizontal: 'left' | 'right' | 'mid';
54
+ vertical: 'top' | 'bottom' | 'mid';
55
+ }
56
+
57
+ /** Buttons are this size by default; sticks and dpads are bigger. */
58
+ const DEFAULT_BUTTON_SIZE = 72;
59
+ const DEFAULT_STICK_SIZE = 130;
60
+
61
+ /** Whether a control is a stick (joystick or dpad). */
62
+ export function isStickType(type: VirtualControlType): boolean {
63
+ return type === 'joystick' || type === 'dpad';
64
+ }
65
+
66
+ /** A control's declared size (before borders). */
67
+ export function declaredSize(definition: Pick<VirtualDef, 'type' | 'size' | 'width' | 'height'>): { w: number; h: number } {
68
+ const base = definition.size ?? (definition.type === 'button' ? DEFAULT_BUTTON_SIZE : DEFAULT_STICK_SIZE);
69
+ return { w: definition.width ?? base, h: definition.height ?? base };
70
+ }
71
+
72
+ /** The rendered size of a control: buttons and taps draw a 2px border outside their declared size; sticks draw theirs inside. */
73
+ export function controlSize(definition: Pick<VirtualDef, 'type' | 'size' | 'width' | 'height'>): { w: number; h: number } {
74
+ const declared = declaredSize(definition);
75
+ const border = isStickType(definition.type) ? 0 : 4;
76
+ return { w: declared.w + border, h: declared.h + border };
77
+ }
78
+
79
+ /** Which edges an anchor hugs. */
80
+ export function anchorSides(anchor: Anchor): AnchorSides {
81
+ return {
82
+ horizontal: anchor.endsWith('left') ? 'left' : anchor.endsWith('right') ? 'right' : 'mid',
83
+ vertical: anchor.startsWith('top') ? 'top' : anchor.startsWith('bottom') ? 'bottom' : 'mid',
84
+ };
85
+ }
86
+
87
+ /** Do two boxes overlap (or come closer than `padding` px)? */
88
+ export function boxesTouch(first: LayoutBox, second: LayoutBox, padding = 0): boolean {
89
+ return first.x < second.x + second.w + padding && second.x < first.x + first.w + padding
90
+ && first.y < second.y + second.h + padding && second.y < first.y + first.h + padding;
91
+ }
92
+
93
+ /** Clamp `amount` into `[low, high]`. */
94
+ export function clampBetween(amount: number, low: number, high: number): number {
95
+ return Math.max(low, Math.min(high, amount));
96
+ }
97
+
98
+ /** The smallest box covering both. */
99
+ export function unionBox(first: LayoutBox, second: LayoutBox): LayoutBox {
100
+ const left = Math.min(first.x, second.x);
101
+ const top = Math.min(first.y, second.y);
102
+ return { x: left, y: top, w: Math.max(first.x + first.w, second.x + second.w) - left, h: Math.max(first.y + first.h, second.y + second.h) - top };
103
+ }
104
+
105
+ /** The box moved inside `area`. */
106
+ export function clampBox(box: LayoutBox, area: Area): LayoutBox {
107
+ return { ...box, x: clampBetween(box.x, area.left, area.right - box.w), y: clampBetween(box.y, area.top, area.bottom - box.h) };
108
+ }
109
+
110
+ /** Whether a box lies inside `area` (half a px of slack). */
111
+ export function isInside(box: LayoutBox, area: Area): boolean {
112
+ return box.x >= area.left - 0.5 && box.y >= area.top - 0.5 && box.x + box.w <= area.right + 0.5 && box.y + box.h <= area.bottom + 0.5;
113
+ }
114
+
115
+ /** Grid step of the free-spot search. */
116
+ const SEARCH_STEP = 4;
117
+
118
+ /** The free spot inside `area` closest to `wanted`, on a 4px grid, or `null`. */
119
+ export function nearestFreeBox(wanted: LayoutBox, area: Area, isFree: (box: LayoutBox) => boolean): LayoutBox | null {
120
+ let best: LayoutBox | null = null;
121
+ let bestDistance = Infinity;
122
+ for (let top = area.top; top <= area.bottom - wanted.h + 0.01; top += SEARCH_STEP) {
123
+ for (let left = area.left; left <= area.right - wanted.w + 0.01; left += SEARCH_STEP) {
124
+ const distance = (left - wanted.x) ** 2 + (top - wanted.y) ** 2;
125
+ if (distance >= bestDistance) {
126
+ continue;
127
+ }
128
+ const candidate = { x: left, y: top, w: wanted.w, h: wanted.h };
129
+ if (isFree(candidate)) {
130
+ best = candidate;
131
+ bestDistance = distance;
132
+ }
133
+ }
134
+ }
135
+ return best;
136
+ }