@diffenderfer-games/hub 0.0.0-stage → 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 -0
- package/README.md +191 -3
- package/dist/auto.d.ts +36 -0
- package/dist/auto.d.ts.map +1 -0
- package/dist/auto.js +27 -0
- package/dist/auto.js.map +1 -0
- package/dist/daily.d.ts +2 -0
- package/dist/daily.js +1 -0
- package/dist/doctor/cli.mjs +268 -0
- package/dist/doctor/cli.mjs.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/input.d.ts +2 -0
- package/dist/input.js +3 -0
- package/dist/mp.d.ts +4 -0
- package/dist/mp.js +1 -0
- package/dist/race.d.ts +4 -0
- package/dist/race.js +1 -0
- package/dist/rooms.d.ts +4 -0
- package/dist/rooms.js +1 -0
- package/dist/shared/bridge.js +209 -0
- package/dist/shared/bridge.js.map +1 -0
- package/dist/shared/client-version.gen.js +7 -0
- package/dist/shared/client-version.gen.js.map +1 -0
- package/dist/shared/daily.d.ts +123 -0
- package/dist/shared/daily.d.ts.map +1 -0
- package/dist/shared/daily.js +191 -0
- package/dist/shared/daily.js.map +1 -0
- package/dist/shared/dom.js +48 -0
- package/dist/shared/dom.js.map +1 -0
- package/dist/shared/globals.js +102 -0
- package/dist/shared/globals.js.map +1 -0
- package/dist/shared/index.d.ts +706 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/input.d.ts +730 -0
- package/dist/shared/input.d.ts.map +1 -0
- package/dist/shared/input.js +2718 -0
- package/dist/shared/input.js.map +1 -0
- package/dist/shared/latest-state.js +17 -0
- package/dist/shared/latest-state.js.map +1 -0
- package/dist/shared/mp.d.ts +9 -0
- package/dist/shared/mp.d.ts.map +1 -0
- package/dist/shared/mp.js +30 -0
- package/dist/shared/mp.js.map +1 -0
- package/dist/shared/multiplayer-types.d.ts +47 -0
- package/dist/shared/multiplayer-types.d.ts.map +1 -0
- package/dist/shared/page-stack.js +207 -0
- package/dist/shared/page-stack.js.map +1 -0
- package/dist/shared/race-types.d.ts +126 -0
- package/dist/shared/race-types.d.ts.map +1 -0
- package/dist/shared/race.d.ts +9 -0
- package/dist/shared/race.d.ts.map +1 -0
- package/dist/shared/race.js +53 -0
- package/dist/shared/race.js.map +1 -0
- package/dist/shared/room-types.d.ts +125 -0
- package/dist/shared/room-types.d.ts.map +1 -0
- package/dist/shared/rooms.d.ts +9 -0
- package/dist/shared/rooms.d.ts.map +1 -0
- package/dist/shared/rooms.js +62 -0
- package/dist/shared/rooms.js.map +1 -0
- package/dist/shared/runtime-opener.js +84 -0
- package/dist/shared/runtime-opener.js.map +1 -0
- package/dist/shared/social.d.ts +148 -0
- package/dist/shared/social.d.ts.map +1 -0
- package/dist/shared/social.js +73 -0
- package/dist/shared/social.js.map +1 -0
- package/dist/shared/src.js +2164 -0
- package/dist/shared/src.js.map +1 -0
- package/dist/shared/wire-types.d.ts +51 -0
- package/dist/shared/wire-types.d.ts.map +1 -0
- package/dist/social.d.ts +6 -0
- package/dist/social.js +3 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.js +0 -0
- package/package.json +89 -6
- package/src/auto.ts +39 -0
- package/src/core/connection.ts +299 -0
- package/src/core/errors.ts +147 -0
- package/src/core/game-data.ts +38 -0
- package/src/core/hub-economy.ts +179 -0
- package/src/core/hub-progress.ts +205 -0
- package/src/core/hub.ts +131 -0
- package/src/core/menu-control.ts +61 -0
- package/src/core/offline/events.ts +50 -0
- package/src/core/offline/favorites.ts +21 -0
- package/src/core/offline/local-store.ts +261 -0
- package/src/core/offline/queue.ts +171 -0
- package/src/core/page.ts +74 -0
- package/src/core/rest/endpoint-types.ts +22 -0
- package/src/core/rest/rest-client.ts +152 -0
- package/src/core/rest/routes.gen.ts +90 -0
- package/src/core/storage/json-guards.ts +74 -0
- package/src/core/storage/key-value.ts +66 -0
- package/src/core/ticket.ts +43 -0
- package/src/core/types.ts +134 -0
- package/src/core/version/client-version.gen.ts +3 -0
- package/src/core/version/hub-version.ts +62 -0
- package/src/core/version/semver.ts +97 -0
- package/src/daily/daily-system.ts +161 -0
- package/src/daily/page-daily.ts +36 -0
- package/src/daily/types.ts +67 -0
- package/src/daily.ts +10 -0
- package/src/doctor/checks.ts +78 -0
- package/src/doctor/cli.ts +98 -0
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -0
- package/src/globals.ts +115 -0
- package/src/index.ts +50 -0
- package/src/input/device-state.ts +146 -0
- package/src/input/edges.ts +55 -0
- package/src/input/gamepad.ts +61 -0
- package/src/input/input-events.ts +32 -0
- package/src/input/input-registry.ts +164 -0
- package/src/input/input-system.ts +393 -0
- package/src/input/legacy-controls.ts +64 -0
- package/src/input/nav-state.ts +191 -0
- package/src/input/navigator.ts +171 -0
- package/src/input/on-screen-controls.ts +101 -0
- package/src/input/page-control.ts +43 -0
- package/src/input/page-input.ts +193 -0
- package/src/input/player-remaps.ts +77 -0
- package/src/input/source-values.ts +116 -0
- package/src/input/spatial.ts +47 -0
- package/src/input/types.ts +331 -0
- package/src/input/zero-config.ts +177 -0
- package/src/input.ts +22 -0
- package/src/mp.ts +10 -0
- package/src/overlay/controls-panel.ts +163 -0
- package/src/overlay/dom.ts +46 -0
- package/src/overlay/layout-clusters.ts +162 -0
- package/src/overlay/layout-geometry.ts +136 -0
- package/src/overlay/layout.ts +203 -0
- package/src/overlay/listener-scope.ts +23 -0
- package/src/overlay/on-screen-keyboard.ts +137 -0
- package/src/overlay/overlay.ts +208 -0
- package/src/overlay/styles.ts +54 -0
- package/src/overlay/virtual-controls.ts +262 -0
- package/src/race.ts +10 -0
- package/src/realtime/bridge.ts +121 -0
- package/src/realtime/cross-bundle.ts +29 -0
- package/src/realtime/fallback-notice.ts +48 -0
- package/src/realtime/hub-link.ts +65 -0
- package/src/realtime/latest-state.ts +33 -0
- package/src/realtime/multiplayer-forwarder.ts +28 -0
- package/src/realtime/multiplayer-types.ts +52 -0
- package/src/realtime/page-runtime.ts +60 -0
- package/src/realtime/race-forwarder.ts +59 -0
- package/src/realtime/race-types.ts +132 -0
- package/src/realtime/room-types.ts +125 -0
- package/src/realtime/rooms-forwarder.ts +79 -0
- package/src/realtime/runtime-api.ts +49 -0
- package/src/realtime/runtime-opener.ts +61 -0
- package/src/realtime/social-forwarder.ts +72 -0
- package/src/realtime/social-types.ts +78 -0
- package/src/realtime/types.ts +53 -0
- package/src/realtime/wire-types.ts +72 -0
- package/src/rooms.ts +10 -0
- package/src/social.ts +18 -0
- package/src/types.ts +13 -0
- package/src/uistack/page-stack.ts +45 -0
- package/src/uistack/play-suspension.ts +82 -0
- package/src/uistack/types.ts +76 -0
- package/src/uistack/ui-stack.ts +144 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Controls screen: rebind inputs, show or hide the on-screen controls,
|
|
3
|
+
* edit the touch layout, reset to defaults.
|
|
4
|
+
*/
|
|
5
|
+
import { readGamepads } from '../input/gamepad';
|
|
6
|
+
import type { InputConfig, InputDef } from '../input/types';
|
|
7
|
+
import { escapeHtml, findElements, requireElement } from './dom';
|
|
8
|
+
|
|
9
|
+
/** What the Controls screen drives (the input system). */
|
|
10
|
+
export interface ControlsPanelHost {
|
|
11
|
+
/** The player's show/hide state of the on-screen controls. */
|
|
12
|
+
readonly showingVirtual: boolean;
|
|
13
|
+
/** Show or hide the on-screen controls (persisted). */
|
|
14
|
+
showVirtual(isVisible: boolean): void;
|
|
15
|
+
/** Force the controls visible while editing the layout. */
|
|
16
|
+
setVirtualEditing(isEditing: boolean): void;
|
|
17
|
+
/** Rebind an input (persisted). */
|
|
18
|
+
rebind(inputName: string, patch: Partial<InputDef>): void;
|
|
19
|
+
/** Forget every remap. */
|
|
20
|
+
resetControls(): void;
|
|
21
|
+
/** A readable label of an input's current binding. */
|
|
22
|
+
bindingLabel(inputName: string): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How long a rebind waits for a key or button. */
|
|
26
|
+
const REBIND_TIMEOUT_MS = 8000;
|
|
27
|
+
|
|
28
|
+
/** The Controls screen markup for `config`. */
|
|
29
|
+
function panelHtml(config: InputConfig, host: ControlsPanelHost): string {
|
|
30
|
+
let rows = '';
|
|
31
|
+
for (const [group, groupDefinition] of Object.entries(config.groups)) {
|
|
32
|
+
rows += `<h4>${escapeHtml(group)}</h4>`;
|
|
33
|
+
for (const name of Object.keys(groupDefinition.inputs)) {
|
|
34
|
+
const escapedName = escapeHtml(name);
|
|
35
|
+
rows += `<div class="cr"><span>${escapedName}</span><b class="bind" data-i="${escapedName}">${escapeHtml(host.bindingLabel(name))}</b><button class="rb" data-i="${escapedName}">Rebind</button></div>`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return `
|
|
39
|
+
<div class="panel-card">
|
|
40
|
+
<h3>Controls</h3>
|
|
41
|
+
<label class="chk"><input type="checkbox" class="showv"> Show on-screen controls</label>
|
|
42
|
+
<button class="btn editlayout">Edit touch layout</button>
|
|
43
|
+
<div class="crs">${rows}</div>
|
|
44
|
+
<div class="panel-actions">
|
|
45
|
+
<button class="btn reset">Reset to defaults</button>
|
|
46
|
+
<button class="btn done">Done</button>
|
|
47
|
+
</div>
|
|
48
|
+
<p class="hint"></p>
|
|
49
|
+
<div class="edit-bar"><span>Drag a control to move it</span><button class="btn editdone">Done</button></div>
|
|
50
|
+
</div>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The Controls screen (one at a time). */
|
|
54
|
+
export class ControlsPanel {
|
|
55
|
+
private readonly root: ShadowRoot;
|
|
56
|
+
private readonly shadowHost: HTMLElement;
|
|
57
|
+
private panel: HTMLDivElement | null = null;
|
|
58
|
+
private isEditingLayout = false;
|
|
59
|
+
|
|
60
|
+
constructor(root: ShadowRoot, shadowHost: HTMLElement) {
|
|
61
|
+
this.root = root;
|
|
62
|
+
this.shadowHost = shadowHost;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether the player is dragging controls around. */
|
|
66
|
+
get isEditing(): boolean {
|
|
67
|
+
return this.isEditingLayout;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Open the screen (or close it when already open). */
|
|
71
|
+
toggle(config: InputConfig | null, host: ControlsPanelHost): void {
|
|
72
|
+
if (config === null) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (this.panel !== null) {
|
|
76
|
+
this.close();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const panel = document.createElement('div');
|
|
80
|
+
panel.className = 'panel';
|
|
81
|
+
panel.innerHTML = panelHtml(config, host);
|
|
82
|
+
this.root.appendChild(panel);
|
|
83
|
+
this.panel = panel;
|
|
84
|
+
const showToggle = requireElement(panel, '.showv', HTMLInputElement);
|
|
85
|
+
showToggle.checked = host.showingVirtual;
|
|
86
|
+
showToggle.addEventListener('change', () => {
|
|
87
|
+
host.showVirtual(showToggle.checked);
|
|
88
|
+
});
|
|
89
|
+
requireElement(panel, '.editlayout', HTMLButtonElement).addEventListener('click', () => {
|
|
90
|
+
this.setEditing(!this.isEditingLayout, host);
|
|
91
|
+
});
|
|
92
|
+
requireElement(panel, '.reset', HTMLButtonElement).addEventListener('click', () => {
|
|
93
|
+
host.resetControls();
|
|
94
|
+
this.close();
|
|
95
|
+
});
|
|
96
|
+
requireElement(panel, '.done', HTMLButtonElement).addEventListener('click', () => {
|
|
97
|
+
this.setEditing(false, host);
|
|
98
|
+
this.close();
|
|
99
|
+
});
|
|
100
|
+
requireElement(panel, '.editdone', HTMLButtonElement).addEventListener('click', () => {
|
|
101
|
+
this.setEditing(false, host);
|
|
102
|
+
});
|
|
103
|
+
for (const button of findElements(panel, '.rb', HTMLButtonElement)) {
|
|
104
|
+
button.addEventListener('click', () => {
|
|
105
|
+
this.captureRebind(button.dataset['i'] ?? '', host, panel);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private close(): void {
|
|
111
|
+
this.panel?.remove();
|
|
112
|
+
this.panel = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private setEditing(isEditing: boolean, host: ControlsPanelHost): void {
|
|
116
|
+
this.isEditingLayout = isEditing;
|
|
117
|
+
this.shadowHost.classList.toggle('editing', isEditing);
|
|
118
|
+
// Forces the controls visible without touching the player's show/hide choice.
|
|
119
|
+
host.setVirtualEditing(isEditing);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wait for the next key or gamepad button and bind `inputName` to it. */
|
|
123
|
+
private captureRebind(inputName: string, host: ControlsPanelHost, panel: HTMLElement): void {
|
|
124
|
+
const hint = requireElement(panel, '.hint', HTMLElement);
|
|
125
|
+
hint.textContent = `Press a key or gamepad button for "${inputName}"…`;
|
|
126
|
+
let frameRequest = 0;
|
|
127
|
+
const startedAt = performance.now();
|
|
128
|
+
const cleanUp = (): void => {
|
|
129
|
+
window.removeEventListener('keydown', onKey, true);
|
|
130
|
+
cancelAnimationFrame(frameRequest);
|
|
131
|
+
};
|
|
132
|
+
const finish = (patch: Partial<InputDef>, label: string): void => {
|
|
133
|
+
host.rebind(inputName, patch);
|
|
134
|
+
const binding = findElements(panel, '.bind', HTMLElement).find((element) => element.dataset['i'] === inputName);
|
|
135
|
+
if (binding !== undefined) {
|
|
136
|
+
binding.textContent = label;
|
|
137
|
+
}
|
|
138
|
+
hint.textContent = '';
|
|
139
|
+
cleanUp();
|
|
140
|
+
};
|
|
141
|
+
function onKey(event: KeyboardEvent): void {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
finish({ keys: [event.key] }, event.key);
|
|
144
|
+
}
|
|
145
|
+
const pollPads = (): void => {
|
|
146
|
+
for (const pad of readGamepads()) {
|
|
147
|
+
const pressed = pad.buttons.findIndex((button) => button.pressed);
|
|
148
|
+
if (pressed >= 0) {
|
|
149
|
+
finish({ gamepad: { button: pressed } }, `Pad ${String(pressed)}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (performance.now() - startedAt > REBIND_TIMEOUT_MS) {
|
|
154
|
+
hint.textContent = '';
|
|
155
|
+
cleanUp();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
frameRequest = requestAnimationFrame(pollPads);
|
|
159
|
+
};
|
|
160
|
+
window.addEventListener('keydown', onKey, true);
|
|
161
|
+
frameRequest = requestAnimationFrame(pollPads);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed DOM helpers for the overlay: element lookups that check the element
|
|
3
|
+
* class instead of asserting it, and small escapes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The first element under `root` matching `selector` that is an instance of `elementClass`; throws if missing. */
|
|
7
|
+
export function requireElement<ElementType extends Element>(root: ParentNode, selector: string, elementClass: new () => ElementType): ElementType {
|
|
8
|
+
const found = root.querySelector(selector);
|
|
9
|
+
if (!(found instanceof elementClass)) {
|
|
10
|
+
throw new Error(`[hub] overlay element ${selector} is missing`);
|
|
11
|
+
}
|
|
12
|
+
return found;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every element under `root` matching `selector` that is an instance of `elementClass`. */
|
|
16
|
+
export function findElements<ElementType extends Element>(root: ParentNode, selector: string, elementClass: new () => ElementType): ElementType[] {
|
|
17
|
+
return [...root.querySelectorAll(selector)].filter((found): found is ElementType => found instanceof elementClass);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `document.body`, which is `null` while the page is still parsing its head (whatever lib.dom says). */
|
|
21
|
+
export function pageBody(): HTMLElement | null {
|
|
22
|
+
return document.body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HTML_ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
26
|
+
|
|
27
|
+
/** Escape text for HTML. */
|
|
28
|
+
export function escapeHtml(text: string): string {
|
|
29
|
+
return text.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character] ?? character);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Clear an element's position styles. */
|
|
33
|
+
export function clearPosition(element: HTMLElement): void {
|
|
34
|
+
element.style.left = '';
|
|
35
|
+
element.style.right = '';
|
|
36
|
+
element.style.top = '';
|
|
37
|
+
element.style.bottom = '';
|
|
38
|
+
element.style.transform = '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Position an element's top-left corner in viewport px. */
|
|
42
|
+
export function placeAt(element: HTMLElement, left: number, top: number): void {
|
|
43
|
+
clearPosition(element);
|
|
44
|
+
element.style.left = `${String(left)}px`;
|
|
45
|
+
element.style.top = `${String(top)}px`;
|
|
46
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|