@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,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
+ }