@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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -21
- package/README.md +191 -139
- package/dist/doctor/cli.mjs +49 -19
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/globals.js +8 -3
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1 -1
- package/package.json +89 -89
- package/src/auto.ts +39 -39
- package/src/core/connection.ts +299 -299
- package/src/core/errors.ts +147 -147
- package/src/core/game-data.ts +38 -38
- package/src/core/hub-economy.ts +179 -179
- package/src/core/hub-progress.ts +205 -205
- package/src/core/hub.ts +131 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/local-store.ts +261 -261
- package/src/core/offline/queue.ts +171 -171
- package/src/core/page.ts +74 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +152 -152
- package/src/core/storage/json-guards.ts +74 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +161 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +67 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +78 -76
- package/src/doctor/cli.ts +98 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/globals.ts +115 -110
- package/src/index.ts +50 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +164 -164
- package/src/input/input-system.ts +393 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/nav-state.ts +191 -191
- package/src/input/navigator.ts +171 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +177 -177
- package/src/input.ts +22 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +208 -208
- package/src/overlay/styles.ts +9 -9
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +121 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +28 -28
- package/src/realtime/multiplayer-types.ts +52 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +125 -125
- package/src/realtime/rooms-forwarder.ts +79 -79
- package/src/realtime/runtime-api.ts +49 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +72 -72
- package/src/realtime/social-types.ts +78 -78
- package/src/realtime/types.ts +53 -53
- package/src/realtime/wire-types.ts +72 -72
- package/src/rooms.ts +10 -10
- package/src/social.ts +18 -18
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
package/src/overlay/layout.ts
CHANGED
|
@@ -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
|
+
}
|