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

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 (293) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/LICENSE +21 -21
  3. package/README.md +203 -139
  4. package/dist/auto.d.ts +8 -8
  5. package/dist/auto.js +3 -3
  6. package/dist/avatar-pixi.d.ts +165 -0
  7. package/dist/avatar-pixi.d.ts.map +1 -0
  8. package/dist/avatar-pixi.js +299 -0
  9. package/dist/avatar-pixi.js.map +1 -0
  10. package/dist/avatar-svg.d.ts +4 -0
  11. package/dist/avatar-svg.js +5 -0
  12. package/dist/avatar-three.d.ts +69 -0
  13. package/dist/avatar-three.d.ts.map +1 -0
  14. package/dist/avatar-three.js +49 -0
  15. package/dist/avatar-three.js.map +1 -0
  16. package/dist/avatar.d.ts +7 -0
  17. package/dist/avatar.js +500 -0
  18. package/dist/avatar.js.map +1 -0
  19. package/dist/daily.d.ts +2 -2
  20. package/dist/doctor/cli.mjs +119 -28
  21. package/dist/doctor/cli.mjs.map +1 -1
  22. package/dist/index.d.ts +8 -8
  23. package/dist/index.js +3 -3
  24. package/dist/input.d.ts +2 -2
  25. package/dist/input.js +2 -2
  26. package/dist/mp.d.ts +3 -3
  27. package/dist/race.d.ts +1 -1
  28. package/dist/rooms.d.ts +3 -3
  29. package/dist/rooms.js +1 -1
  30. package/dist/shared/animator.d.ts +104 -0
  31. package/dist/shared/animator.d.ts.map +1 -0
  32. package/dist/shared/art.js +1946 -0
  33. package/dist/shared/art.js.map +1 -0
  34. package/dist/shared/atlas.d.ts +138 -0
  35. package/dist/shared/atlas.d.ts.map +1 -0
  36. package/dist/shared/avatar.d.ts +123 -0
  37. package/dist/shared/avatar.d.ts.map +1 -0
  38. package/dist/shared/bridge.js +18 -4
  39. package/dist/shared/bridge.js.map +1 -1
  40. package/dist/shared/client-version.gen.js +1 -1
  41. package/dist/shared/daily.d.ts +145 -19
  42. package/dist/shared/daily.d.ts.map +1 -1
  43. package/dist/shared/daily.js +138 -25
  44. package/dist/shared/daily.js.map +1 -1
  45. package/dist/shared/defineProperty.js +39 -0
  46. package/dist/shared/describe.d.ts +32 -0
  47. package/dist/shared/describe.d.ts.map +1 -0
  48. package/dist/shared/describe.js +109 -0
  49. package/dist/shared/describe.js.map +1 -0
  50. package/dist/shared/globals.js +9 -42
  51. package/dist/shared/globals.js.map +1 -1
  52. package/dist/shared/index.d.ts +1122 -46
  53. package/dist/shared/index.d.ts.map +1 -1
  54. package/dist/shared/input.d.ts +74 -14
  55. package/dist/shared/input.d.ts.map +1 -1
  56. package/dist/shared/input.js +179 -25
  57. package/dist/shared/input.js.map +1 -1
  58. package/dist/shared/json-guards.js +48 -0
  59. package/dist/shared/json-guards.js.map +1 -0
  60. package/dist/shared/latest-state.js +1 -1
  61. package/dist/shared/live-canvas.js +62 -0
  62. package/dist/shared/live-canvas.js.map +1 -0
  63. package/dist/shared/mp.js +10 -2
  64. package/dist/shared/mp.js.map +1 -1
  65. package/dist/shared/multiplayer-types.d.ts +15 -2
  66. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  67. package/dist/shared/overlays.js +800 -0
  68. package/dist/shared/overlays.js.map +1 -0
  69. package/dist/shared/page-stack.js +2 -1
  70. package/dist/shared/page-stack.js.map +1 -1
  71. package/dist/shared/pose-key.js +249 -0
  72. package/dist/shared/pose-key.js.map +1 -0
  73. package/dist/shared/race-types.d.ts +1 -1
  74. package/dist/shared/race.js +1 -1
  75. package/dist/shared/room-types.d.ts +28 -3
  76. package/dist/shared/room-types.d.ts.map +1 -1
  77. package/dist/shared/rooms.d.ts.map +1 -1
  78. package/dist/shared/rooms.js +23 -4
  79. package/dist/shared/rooms.js.map +1 -1
  80. package/dist/shared/runtime-opener.js +1 -1
  81. package/dist/shared/scene.d.ts +90 -0
  82. package/dist/shared/scene.d.ts.map +1 -0
  83. package/dist/shared/social.d.ts +90 -9
  84. package/dist/shared/social.d.ts.map +1 -1
  85. package/dist/shared/social.js +6 -1
  86. package/dist/shared/social.js.map +1 -1
  87. package/dist/shared/src.js +4563 -667
  88. package/dist/shared/src.js.map +1 -1
  89. package/dist/shared/svg-markup.js +2354 -0
  90. package/dist/shared/svg-markup.js.map +1 -0
  91. package/dist/shared/svg.js +27 -0
  92. package/dist/shared/svg.js.map +1 -0
  93. package/dist/shared/types.d.ts +174 -0
  94. package/dist/shared/types.d.ts.map +1 -0
  95. package/dist/shared/wire-types.d.ts +6 -2
  96. package/dist/shared/wire-types.d.ts.map +1 -1
  97. package/dist/social.d.ts +5 -5
  98. package/dist/social.js +2 -2
  99. package/dist/turns-rules.d.ts +2 -0
  100. package/dist/turns-rules.js +1 -0
  101. package/dist/turns.d.ts +61 -0
  102. package/dist/turns.d.ts.map +1 -0
  103. package/dist/turns.js +183 -0
  104. package/dist/turns.js.map +1 -0
  105. package/package.json +125 -89
  106. package/src/auto.ts +39 -39
  107. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  108. package/src/avatar/adapters/pixi-crowd.ts +329 -0
  109. package/src/avatar/adapters/pixi-types.ts +84 -0
  110. package/src/avatar/adapters/three.ts +126 -0
  111. package/src/avatar/animator/animator.ts +285 -0
  112. package/src/avatar/animator/reactions.ts +44 -0
  113. package/src/avatar/animator/schedule.ts +73 -0
  114. package/src/avatar/art/beard.ts +49 -0
  115. package/src/avatar/art/glasses.ts +67 -0
  116. package/src/avatar/art/hair.ts +254 -0
  117. package/src/avatar/art/headwear.ts +75 -0
  118. package/src/avatar/art/index.ts +21 -0
  119. package/src/avatar/art/shape.ts +87 -0
  120. package/src/avatar/art-store.ts +36 -0
  121. package/src/avatar/atlas.ts +246 -0
  122. package/src/avatar/catalog-view.ts +14 -0
  123. package/src/avatar/colour.ts +109 -0
  124. package/src/avatar/describe.ts +79 -0
  125. package/src/avatar/face.ts +187 -0
  126. package/src/avatar/geom.ts +173 -0
  127. package/src/avatar/layout.ts +47 -0
  128. package/src/avatar/look-like.ts +52 -0
  129. package/src/avatar/outputs/bitmap.ts +75 -0
  130. package/src/avatar/outputs/canvas-paint.ts +177 -0
  131. package/src/avatar/outputs/canvas.ts +62 -0
  132. package/src/avatar/outputs/custom-element.ts +140 -0
  133. package/src/avatar/outputs/element.ts +58 -0
  134. package/src/avatar/outputs/live-canvas.ts +57 -0
  135. package/src/avatar/outputs/overlay-svg.ts +22 -0
  136. package/src/avatar/outputs/pose-key.ts +30 -0
  137. package/src/avatar/outputs/svg-markup.ts +113 -0
  138. package/src/avatar/outputs/svg.ts +41 -0
  139. package/src/avatar/overlays.ts +128 -0
  140. package/src/avatar/render/body.ts +116 -0
  141. package/src/avatar/render/colours.ts +81 -0
  142. package/src/avatar/render/compose.ts +320 -0
  143. package/src/avatar/render/context.ts +117 -0
  144. package/src/avatar/render/eyes.ts +78 -0
  145. package/src/avatar/render/eyewear.ts +118 -0
  146. package/src/avatar/render/frame.ts +73 -0
  147. package/src/avatar/render/head.ts +81 -0
  148. package/src/avatar/render/mouth.ts +86 -0
  149. package/src/avatar/render/props.ts +91 -0
  150. package/src/avatar/render/types.ts +93 -0
  151. package/src/avatar/render/walker-parts.ts +121 -0
  152. package/src/avatar/render/walker.ts +199 -0
  153. package/src/avatar/scene.ts +119 -0
  154. package/src/avatar-pixi.ts +20 -0
  155. package/src/avatar-svg.ts +21 -0
  156. package/src/avatar-three.ts +18 -0
  157. package/src/avatar.ts +63 -0
  158. package/src/core/avatar/avatar-cache.ts +95 -0
  159. package/src/core/avatar/avatar-ref.ts +34 -0
  160. package/src/core/avatar/avatar-types.ts +43 -0
  161. package/src/core/avatar/hub-avatar.ts +308 -0
  162. package/src/core/connection.ts +408 -299
  163. package/src/core/errors.ts +160 -147
  164. package/src/core/game-data.ts +40 -38
  165. package/src/core/hub-economy.ts +216 -179
  166. package/src/core/hub-progress.ts +269 -205
  167. package/src/core/hub.ts +352 -131
  168. package/src/core/menu-control.ts +61 -61
  169. package/src/core/offline/cloud-saves.ts +62 -0
  170. package/src/core/offline/events.ts +50 -50
  171. package/src/core/offline/favorites.ts +21 -21
  172. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  173. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  174. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  175. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  176. package/src/core/offline/hub-kinds/index.ts +52 -0
  177. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  178. package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
  179. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  180. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  181. package/src/core/offline/kind-adapters.ts +48 -0
  182. package/src/core/offline/local-inventory.ts +64 -0
  183. package/src/core/offline/local-store.ts +304 -261
  184. package/src/core/offline/offline-api.ts +65 -0
  185. package/src/core/offline/outbox-entry.ts +59 -0
  186. package/src/core/offline/outbox-kinds.ts +78 -0
  187. package/src/core/offline/outbox-merge.ts +39 -0
  188. package/src/core/offline/outbox-replayer.ts +141 -0
  189. package/src/core/offline/outbox-store.ts +85 -0
  190. package/src/core/offline/outbox.ts +232 -0
  191. package/src/core/offline/queue.ts +126 -171
  192. package/src/core/offline/replay-lease.ts +87 -0
  193. package/src/core/offline/replay-policy.ts +52 -0
  194. package/src/core/page-account.ts +121 -0
  195. package/src/core/page.ts +87 -74
  196. package/src/core/rest/endpoint-types.ts +22 -22
  197. package/src/core/rest/rest-client.ts +163 -152
  198. package/src/core/rest/routes.gen.ts +144 -88
  199. package/src/core/resume/hub-resume.ts +286 -0
  200. package/src/core/resume/local-resume.ts +130 -0
  201. package/src/core/resume/resume-list.ts +65 -0
  202. package/src/core/resume/resume-request.ts +52 -0
  203. package/src/core/resume/resume-types.ts +64 -0
  204. package/src/core/saved/daily-saves.ts +196 -0
  205. package/src/core/saved/daily-seed.ts +16 -0
  206. package/src/core/saved/hub-saved.ts +221 -0
  207. package/src/core/saved/local-saved.ts +122 -0
  208. package/src/core/saved/saved-list.ts +129 -0
  209. package/src/core/saved/saved-open.ts +104 -0
  210. package/src/core/saved/saved-order.ts +49 -0
  211. package/src/core/saved/saved-types.ts +24 -0
  212. package/src/core/storage/json-guards.ts +80 -74
  213. package/src/core/storage/key-value.ts +66 -66
  214. package/src/core/ticket.ts +43 -43
  215. package/src/core/turns/hub-turns.ts +161 -0
  216. package/src/core/turns/turn-game.ts +510 -0
  217. package/src/core/turns/your-turn-buzz.ts +69 -0
  218. package/src/core/types.ts +134 -134
  219. package/src/core/version/client-version.gen.ts +2 -2
  220. package/src/core/version/hub-version.ts +62 -62
  221. package/src/core/version/semver.ts +97 -97
  222. package/src/daily/daily-system.ts +276 -161
  223. package/src/daily/page-daily.ts +36 -36
  224. package/src/daily/types.ts +105 -67
  225. package/src/daily.ts +10 -10
  226. package/src/doctor/checks.ts +86 -76
  227. package/src/doctor/cli.ts +101 -106
  228. package/src/doctor/hub-version-fetch.ts +43 -0
  229. package/src/doctor/tsconfig.json +12 -12
  230. package/src/doctor/world-check.ts +61 -0
  231. package/src/globals.ts +115 -110
  232. package/src/index.ts +68 -50
  233. package/src/input/device-state.ts +146 -146
  234. package/src/input/edges.ts +55 -55
  235. package/src/input/focus-ring.ts +13 -0
  236. package/src/input/gamepad.ts +61 -61
  237. package/src/input/input-events.ts +32 -32
  238. package/src/input/input-registry.ts +171 -164
  239. package/src/input/input-system.ts +406 -393
  240. package/src/input/legacy-controls.ts +64 -64
  241. package/src/input/modality.ts +122 -0
  242. package/src/input/nav-state.ts +200 -191
  243. package/src/input/navigator.ts +182 -171
  244. package/src/input/on-screen-controls.ts +101 -101
  245. package/src/input/page-control.ts +43 -43
  246. package/src/input/page-input.ts +193 -193
  247. package/src/input/player-remaps.ts +77 -77
  248. package/src/input/source-values.ts +116 -116
  249. package/src/input/spatial.ts +47 -47
  250. package/src/input/types.ts +331 -331
  251. package/src/input/zero-config.ts +164 -177
  252. package/src/input.ts +26 -22
  253. package/src/mp.ts +10 -10
  254. package/src/overlay/controls-panel.ts +163 -163
  255. package/src/overlay/dom.ts +46 -46
  256. package/src/overlay/layout-clusters.ts +162 -162
  257. package/src/overlay/layout-geometry.ts +136 -136
  258. package/src/overlay/layout.ts +203 -203
  259. package/src/overlay/listener-scope.ts +23 -23
  260. package/src/overlay/on-screen-keyboard.ts +137 -137
  261. package/src/overlay/overlay.ts +234 -208
  262. package/src/overlay/styles.ts +13 -12
  263. package/src/overlay/virtual-controls.ts +262 -262
  264. package/src/race.ts +10 -10
  265. package/src/realtime/bridge.ts +126 -121
  266. package/src/realtime/cross-bundle.ts +29 -29
  267. package/src/realtime/fallback-notice.ts +48 -48
  268. package/src/realtime/hub-link.ts +65 -65
  269. package/src/realtime/latest-state.ts +33 -33
  270. package/src/realtime/multiplayer-forwarder.ts +40 -28
  271. package/src/realtime/multiplayer-types.ts +66 -52
  272. package/src/realtime/page-runtime.ts +60 -60
  273. package/src/realtime/race-forwarder.ts +59 -59
  274. package/src/realtime/race-types.ts +132 -132
  275. package/src/realtime/room-types.ts +151 -125
  276. package/src/realtime/rooms-forwarder.ts +104 -79
  277. package/src/realtime/runtime-api.ts +74 -49
  278. package/src/realtime/runtime-opener.ts +61 -61
  279. package/src/realtime/social-forwarder.ts +77 -72
  280. package/src/realtime/social-types.ts +86 -78
  281. package/src/realtime/types.ts +54 -53
  282. package/src/realtime/wire-types.ts +79 -72
  283. package/src/realtime/world-types.ts +20 -0
  284. package/src/rooms.ts +10 -10
  285. package/src/social.ts +19 -18
  286. package/src/turns-kit/check-rules.ts +281 -0
  287. package/src/turns-rules.ts +12 -0
  288. package/src/turns.ts +14 -0
  289. package/src/types.ts +13 -13
  290. package/src/uistack/page-stack.ts +45 -45
  291. package/src/uistack/play-suspension.ts +82 -82
  292. package/src/uistack/types.ts +76 -76
  293. 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
+ }