@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,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's one input system (plan §4.4). The game's copy of the client owns
|
|
3
|
+
* the page because the game defines the controls; the host menu reaches it
|
|
4
|
+
* through the stable {@link PageInputControl} slice. Remaps persist to
|
|
5
|
+
* localStorage (offline-safe) and, through the core client, to a reserved
|
|
6
|
+
* cloud save slot.
|
|
7
|
+
*/
|
|
8
|
+
import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
|
|
9
|
+
import { claimSingleton, LEGACY_GLOBAL_NAMES } from '../globals';
|
|
10
|
+
import { hasMethods, isObject, readField } from '../realtime/cross-bundle';
|
|
11
|
+
import { InputSystem } from './input-system';
|
|
12
|
+
import { isPageInputControl } from './page-control';
|
|
13
|
+
import type { PageInputControl } from './page-control';
|
|
14
|
+
import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
|
|
15
|
+
|
|
16
|
+
/** The cross-bundle shape number of the `input` singleton. Bump only on an incompatible change. */
|
|
17
|
+
export const INPUT_SINGLETON_API = 1;
|
|
18
|
+
|
|
19
|
+
/** The public methods of every input system, v1 copies included. */
|
|
20
|
+
const INPUT_SYSTEM_METHODS = [
|
|
21
|
+
'define', 'enable', 'disable', 'isEnabled', 'value', 'get', 'down', 'up', 'pressed', 'axis', 'vector', 'on',
|
|
22
|
+
'setVirtualHtml', 'showVirtual', 'setInsets', 'autoNavigate', 'clearAutoNavigate', 'attachMenu', 'setLegacyControls',
|
|
23
|
+
'openControls', 'rebind', 'setVirtual',
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
/** Whether `candidate` is some bundle's input system (v1 copies included). */
|
|
27
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
|
|
28
|
+
export function isInputSystem(candidate: unknown): candidate is InputSystem {
|
|
29
|
+
return hasMethods(candidate, INPUT_SYSTEM_METHODS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How the core client connects the input system to its game. */
|
|
33
|
+
export interface InputOptions {
|
|
34
|
+
/** This page's game slug (default: `window.__HUB__.slug`). */
|
|
35
|
+
slug?: string | null;
|
|
36
|
+
/** Mirror remaps to the cloud (the core client writes the `__controls` save slot). */
|
|
37
|
+
saveToCloud?: (overrides: InputOverrides) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A finite number, or `undefined`. */
|
|
41
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
|
|
42
|
+
function finiteNumber(candidate: unknown): number | undefined {
|
|
43
|
+
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A saved rebind: the keys or gamepad button the player chose. */
|
|
47
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
|
|
48
|
+
function parseBinding(saved: unknown): Partial<InputDef> {
|
|
49
|
+
const binding: Partial<InputDef> = {};
|
|
50
|
+
const keys = readField(saved, 'keys');
|
|
51
|
+
if (Array.isArray(keys) && keys.every((key) => typeof key === 'string')) {
|
|
52
|
+
binding.keys = keys.filter((key): key is string => typeof key === 'string');
|
|
53
|
+
}
|
|
54
|
+
const button = finiteNumber(readField(readField(saved, 'gamepad'), 'button'));
|
|
55
|
+
if (button !== undefined) {
|
|
56
|
+
binding.gamepad = { button };
|
|
57
|
+
}
|
|
58
|
+
return binding;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A saved dragged spot. */
|
|
62
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
|
|
63
|
+
function parseVirtualOverride(saved: unknown): VirtualOverride {
|
|
64
|
+
const spot: VirtualOverride = {};
|
|
65
|
+
const left = finiteNumber(readField(saved, 'x'));
|
|
66
|
+
const top = finiteNumber(readField(saved, 'y'));
|
|
67
|
+
if (left !== undefined) {
|
|
68
|
+
spot.x = left;
|
|
69
|
+
}
|
|
70
|
+
if (top !== undefined) {
|
|
71
|
+
spot.y = top;
|
|
72
|
+
}
|
|
73
|
+
return spot;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Entries of a saved record, parsed. */
|
|
77
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: saved JSON
|
|
78
|
+
function parseRecord<Parsed>(saved: unknown, parseEntry: (entry: unknown) => Parsed): Record<string, Parsed> | undefined {
|
|
79
|
+
if (!isObject(saved)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return Object.fromEntries(Object.entries(saved).map(([key, entry]) => [key, parseEntry(entry)]));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Saved overrides from JSON, keeping only well-formed fields. */
|
|
86
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from localStorage
|
|
87
|
+
export function parseInputOverrides(saved: unknown): InputOverrides {
|
|
88
|
+
const overrides: InputOverrides = {};
|
|
89
|
+
const bindings = parseRecord(readField(saved, 'bindings'), parseBinding);
|
|
90
|
+
const virtual = parseRecord(readField(saved, 'virtual'), parseVirtualOverride);
|
|
91
|
+
const showVirtual = readField(saved, 'showVirtual');
|
|
92
|
+
if (bindings !== undefined) {
|
|
93
|
+
overrides.bindings = bindings;
|
|
94
|
+
}
|
|
95
|
+
if (virtual !== undefined) {
|
|
96
|
+
overrides.virtual = virtual;
|
|
97
|
+
}
|
|
98
|
+
if (typeof showVirtual === 'boolean') {
|
|
99
|
+
overrides.showVirtual = showVirtual;
|
|
100
|
+
}
|
|
101
|
+
return overrides;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A field of the host's injected `window.__HUB__`. */
|
|
105
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the host page's injected config, checked by the caller
|
|
106
|
+
function injectedField(key: string): unknown {
|
|
107
|
+
return typeof window === 'undefined' ? undefined : readField(readField(window, '__HUB__'), key);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The slug the host injected, or `null`. */
|
|
111
|
+
function injectedSlug(): string | null {
|
|
112
|
+
const slug = injectedField('slug');
|
|
113
|
+
return typeof slug === 'string' ? slug : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A new input system, starting from the host's injected `game.controls` (the menu may pass them again later). */
|
|
117
|
+
function createInputSystem(options: InputOptions, isPageOwner: boolean): InputSystem {
|
|
118
|
+
const system = new InputSystem(createInputHost(options), { isPageOwner, claimPage: registerAsPageInput });
|
|
119
|
+
system.setLegacyControls(injectedField('controls') ?? null);
|
|
120
|
+
return system;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Persistence for remaps: localStorage under `hub:<slug>:input`, mirrored to the cloud when given. */
|
|
124
|
+
function createInputHost(options: InputOptions): InputHost {
|
|
125
|
+
const slug = options.slug === undefined ? injectedSlug() : options.slug;
|
|
126
|
+
const storageKey = `hub:${slug ?? 'null'}:input`;
|
|
127
|
+
return {
|
|
128
|
+
slug,
|
|
129
|
+
loadOverrides() {
|
|
130
|
+
try {
|
|
131
|
+
const saved = localStorage.getItem(storageKey);
|
|
132
|
+
if (saved === null) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON from localStorage, parsed straight away
|
|
136
|
+
const parsed: unknown = JSON.parse(saved);
|
|
137
|
+
return parseInputOverrides(parsed);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
saveOverrides(overrides) {
|
|
143
|
+
try {
|
|
144
|
+
localStorage.setItem(storageKey, JSON.stringify(overrides));
|
|
145
|
+
} catch {
|
|
146
|
+
// Storage full or blocked: the cloud copy still saves.
|
|
147
|
+
}
|
|
148
|
+
if (slug !== null) {
|
|
149
|
+
options.saveToCloud?.(overrides);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Make `system` the page's registered input system; returns the one it replaces. */
|
|
156
|
+
function registerAsPageInput(system: InputSystem): PageInputControl | null {
|
|
157
|
+
const registry = window.__DG__ ?? {};
|
|
158
|
+
window.__DG__ = registry;
|
|
159
|
+
const previous = registry.input?.instance ?? window[LEGACY_GLOBAL_NAMES.input];
|
|
160
|
+
registry.input = { version: HUB_CLIENT_VERSION, api: INPUT_SINGLETON_API, instance: system };
|
|
161
|
+
window[LEGACY_GLOBAL_NAMES.input] = system;
|
|
162
|
+
return previous !== system && isPageInputControl(previous) ? previous : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether another bundle registered an input system with a different shape number. */
|
|
166
|
+
function hasMismatchedPageInput(): boolean {
|
|
167
|
+
const registered = typeof window === 'undefined' ? undefined : window.__DG__?.input;
|
|
168
|
+
return registered !== undefined && registered.api !== INPUT_SINGLETON_API && isInputSystem(registered.instance);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let claimedInput: InputSystem | null = null;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The page-wide input system, created on first use. `options` connect it to
|
|
175
|
+
* the core client (its slug, the cloud save); the first call's options win.
|
|
176
|
+
* When another client copy with a different shape owns the page, this copy
|
|
177
|
+
* gets a private instance that takes the page over once its game defines
|
|
178
|
+
* inputs.
|
|
179
|
+
*/
|
|
180
|
+
export function getInput(options: InputOptions = {}): InputSystem {
|
|
181
|
+
if (claimedInput !== null) {
|
|
182
|
+
return claimedInput;
|
|
183
|
+
}
|
|
184
|
+
const isPrivate = hasMismatchedPageInput();
|
|
185
|
+
claimedInput = claimSingleton({
|
|
186
|
+
name: 'input',
|
|
187
|
+
version: HUB_CLIENT_VERSION,
|
|
188
|
+
api: INPUT_SINGLETON_API,
|
|
189
|
+
create: () => createInputSystem(options, !isPrivate),
|
|
190
|
+
isInstance: isInputSystem,
|
|
191
|
+
});
|
|
192
|
+
return claimedInput;
|
|
193
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The player's remaps and on-screen control customisations for one game,
|
|
3
|
+
* persisted through the input host after every change.
|
|
4
|
+
*/
|
|
5
|
+
import type { InputDef, InputHost, InputOverrides, VirtualOverride } from './types';
|
|
6
|
+
|
|
7
|
+
/** A readable label of an input's binding (`w/ArrowUp · Pad 0`). */
|
|
8
|
+
export function describeBinding(definition: InputDef | undefined): string {
|
|
9
|
+
const parts: string[] = [];
|
|
10
|
+
if (definition?.keys !== undefined && definition.keys.length > 0) {
|
|
11
|
+
parts.push(definition.keys.join('/'));
|
|
12
|
+
}
|
|
13
|
+
if (definition?.gamepad?.button !== undefined) {
|
|
14
|
+
parts.push(`Pad ${String(definition.gamepad.button)}`);
|
|
15
|
+
}
|
|
16
|
+
if (definition?.gamepad?.axis !== undefined) {
|
|
17
|
+
parts.push(`Axis ${String(definition.gamepad.axis[0])}${definition.gamepad.axis[1]}`);
|
|
18
|
+
}
|
|
19
|
+
return parts.length > 0 ? parts.join(' · ') : '—';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The player's overrides, saved on every change. */
|
|
23
|
+
export class PlayerRemaps {
|
|
24
|
+
private readonly host: InputHost;
|
|
25
|
+
private saved: InputOverrides;
|
|
26
|
+
|
|
27
|
+
constructor(host: InputHost) {
|
|
28
|
+
this.host = host;
|
|
29
|
+
this.saved = host.loadOverrides() ?? {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The overrides as they stand. */
|
|
33
|
+
get overrides(): InputOverrides {
|
|
34
|
+
return this.saved;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A declared input with the player's rebind applied. */
|
|
38
|
+
withBinding(name: string, declared: InputDef): InputDef {
|
|
39
|
+
return { ...declared, ...this.saved.bindings?.[name] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Rebind an input. */
|
|
43
|
+
rebind(inputName: string, patch: Partial<InputDef>): void {
|
|
44
|
+
const bindings = this.saved.bindings ?? {};
|
|
45
|
+
bindings[inputName] = { ...bindings[inputName], ...patch };
|
|
46
|
+
this.saved.bindings = bindings;
|
|
47
|
+
this.persist();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Move or restyle an on-screen control. */
|
|
51
|
+
customiseControl(id: string, patch: VirtualOverride): void {
|
|
52
|
+
const virtual = this.saved.virtual ?? {};
|
|
53
|
+
virtual[id] = { ...virtual[id], ...patch };
|
|
54
|
+
this.saved.virtual = virtual;
|
|
55
|
+
this.persist();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Record the player's show/hide choice. */
|
|
59
|
+
setShowVirtual(isVisible: boolean): void {
|
|
60
|
+
this.saved.showVirtual = isVisible;
|
|
61
|
+
this.persist();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Forget everything. */
|
|
65
|
+
reset(): void {
|
|
66
|
+
this.saved = {};
|
|
67
|
+
this.persist();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private persist(): void {
|
|
71
|
+
try {
|
|
72
|
+
this.host.saveOverrides(this.saved);
|
|
73
|
+
} catch {
|
|
74
|
+
// Storage can be full or blocked; remaps still apply for this visit.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An input's raw 0..1 value from the device state: the strongest of its
|
|
3
|
+
* sources. Keyboard and touch always count; mouse and gamepad only while
|
|
4
|
+
* they are the active pointer.
|
|
5
|
+
*/
|
|
6
|
+
import type { DeviceState } from './device-state';
|
|
7
|
+
import { AXIS_DEADZONE } from './gamepad';
|
|
8
|
+
import type { AxisSide, GamepadMapping, InputDef, InputSource, MouseMapping, TouchMapping } from './types';
|
|
9
|
+
|
|
10
|
+
/** Clamp to 0..1. */
|
|
11
|
+
export function clampUnit(amount: number): number {
|
|
12
|
+
return Math.min(1, Math.max(0, amount));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The pointer's position from screen centre on an axis, as 0..1 on one side. */
|
|
16
|
+
function centreValue(state: DeviceState, axis: 'x' | 'y', side: AxisSide | undefined): number {
|
|
17
|
+
const fraction = axis === 'x' ? state.pointerX / window.innerWidth : state.pointerY / window.innerHeight;
|
|
18
|
+
const signed = (fraction - 0.5) * 2;
|
|
19
|
+
return clampUnit(side === '-' ? -signed : signed);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A signed amount read on one side. */
|
|
23
|
+
function onSide(amount: number, side: AxisSide | undefined): number {
|
|
24
|
+
return side === '-' ? -amount : amount;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mouseValue(state: DeviceState, mapping: MouseMapping): number {
|
|
28
|
+
let strongest = 0;
|
|
29
|
+
if (mapping.button !== undefined && state.heldMouseButtons.has(mapping.button)) {
|
|
30
|
+
strongest = 1;
|
|
31
|
+
}
|
|
32
|
+
if (mapping.dblclick === true && state.isPulseActive('mouse:dbl')) {
|
|
33
|
+
strongest = 1;
|
|
34
|
+
}
|
|
35
|
+
if (mapping.centerAxis !== undefined) {
|
|
36
|
+
strongest = Math.max(strongest, centreValue(state, mapping.centerAxis, mapping.centerSide));
|
|
37
|
+
}
|
|
38
|
+
if (mapping.moveAxis !== undefined) {
|
|
39
|
+
const moved = mapping.moveAxis === 'x' ? state.movement.dx : state.movement.dy;
|
|
40
|
+
strongest = Math.max(strongest, clampUnit(onSide(moved, mapping.moveSign) * (mapping.moveScale ?? 0.1)));
|
|
41
|
+
}
|
|
42
|
+
if (mapping.wheel !== undefined) {
|
|
43
|
+
strongest = Math.max(strongest, clampUnit(onSide(state.wheel.dy, mapping.wheel)));
|
|
44
|
+
}
|
|
45
|
+
return strongest;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function gamepadValue(state: DeviceState, mapping: GamepadMapping): number {
|
|
49
|
+
let strongest = 0;
|
|
50
|
+
for (const pad of state.gamepads) {
|
|
51
|
+
if (mapping.button !== undefined) {
|
|
52
|
+
const button = pad.buttons[mapping.button];
|
|
53
|
+
if (button !== undefined) {
|
|
54
|
+
strongest = Math.max(strongest, button.value > 0 ? button.value : Number(button.pressed));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (mapping.axis !== undefined) {
|
|
58
|
+
const [axisIndex, half] = mapping.axis;
|
|
59
|
+
const deflection = pad.axes[axisIndex] ?? 0;
|
|
60
|
+
const reading = half === '±' ? Math.abs(deflection) : Math.max(0, half === '+' ? deflection : -deflection);
|
|
61
|
+
if (Math.abs(deflection) > AXIS_DEADZONE) {
|
|
62
|
+
strongest = Math.max(strongest, clampUnit(reading));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return strongest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stickValue(state: DeviceState, mapping: TouchMapping): number {
|
|
70
|
+
const stick = mapping.stick === undefined ? undefined : state.sticks.get(mapping.stick);
|
|
71
|
+
if (stick === undefined) {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const readings: Record<NonNullable<TouchMapping['axis']>, number> = {
|
|
75
|
+
'x+': Math.max(0, stick.outX),
|
|
76
|
+
'x-': Math.max(0, -stick.outX),
|
|
77
|
+
'y+': Math.max(0, stick.outY),
|
|
78
|
+
'y-': Math.max(0, -stick.outY),
|
|
79
|
+
x: Math.abs(stick.outX),
|
|
80
|
+
y: Math.abs(stick.outY),
|
|
81
|
+
};
|
|
82
|
+
return mapping.axis === undefined ? 0 : readings[mapping.axis];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function touchValue(state: DeviceState, mapping: TouchMapping): number {
|
|
86
|
+
let strongest = clampUnit(stickValue(state, mapping));
|
|
87
|
+
if (mapping.button !== undefined && state.pressedVirtualButtons.has(mapping.button)) {
|
|
88
|
+
strongest = 1;
|
|
89
|
+
}
|
|
90
|
+
if (mapping.tap === true && state.isPulseActive('tap')) {
|
|
91
|
+
strongest = 1;
|
|
92
|
+
}
|
|
93
|
+
if (mapping.centerAxis !== undefined) {
|
|
94
|
+
// The last touch position (touch moves the pointer too).
|
|
95
|
+
strongest = Math.max(strongest, centreValue(state, mapping.centerAxis, mapping.centerSide));
|
|
96
|
+
}
|
|
97
|
+
return strongest;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An input's raw value now, given the active pointer. */
|
|
101
|
+
export function readRawValue(state: DeviceState, definition: InputDef, activeSource: InputSource): number {
|
|
102
|
+
let strongest = 0;
|
|
103
|
+
if (definition.keys?.some((key) => state.heldKeys.has(key.toLowerCase())) === true) {
|
|
104
|
+
strongest = 1;
|
|
105
|
+
}
|
|
106
|
+
if (definition.mouse !== undefined && activeSource === 'mouse') {
|
|
107
|
+
strongest = Math.max(strongest, mouseValue(state, definition.mouse));
|
|
108
|
+
}
|
|
109
|
+
if (definition.gamepad !== undefined && activeSource === 'gamepad') {
|
|
110
|
+
strongest = Math.max(strongest, gamepadValue(state, definition.gamepad));
|
|
111
|
+
}
|
|
112
|
+
if (definition.touch !== undefined) {
|
|
113
|
+
strongest = Math.max(strongest, touchValue(state, definition.touch));
|
|
114
|
+
}
|
|
115
|
+
return clampUnit(strongest);
|
|
116
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial navigation: from one rectangle, the nearest one strictly in a
|
|
3
|
+
* direction. Shared by navigable groups, the hub menu, the catalog launcher
|
|
4
|
+
* and the on-screen keyboard.
|
|
5
|
+
*/
|
|
6
|
+
import type { Direction } from './gamepad';
|
|
7
|
+
import type { Rect } from './types';
|
|
8
|
+
|
|
9
|
+
/** Centres closer than this on the travel axis don't count as "in that direction". */
|
|
10
|
+
const MIN_TRAVEL_PX = 4;
|
|
11
|
+
|
|
12
|
+
/** The rectangle of a DOM element. */
|
|
13
|
+
export function rectOf(element: Element): Rect {
|
|
14
|
+
const bounds = element.getBoundingClientRect();
|
|
15
|
+
return { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Index of the rectangle nearest `rects[fromIndex]` strictly in `direction`, or -1. Off-axis distance counts double. */
|
|
19
|
+
export function findNearestInDirection(rects: readonly Rect[], fromIndex: number, direction: Direction): number {
|
|
20
|
+
const from = rects[fromIndex];
|
|
21
|
+
if (from === undefined) {
|
|
22
|
+
return -1;
|
|
23
|
+
}
|
|
24
|
+
const fromCentreX = from.x + from.width / 2;
|
|
25
|
+
const fromCentreY = from.y + from.height / 2;
|
|
26
|
+
const isVertical = direction === 'up' || direction === 'down';
|
|
27
|
+
let bestIndex = -1;
|
|
28
|
+
let bestScore = Infinity;
|
|
29
|
+
rects.forEach((rect, index) => {
|
|
30
|
+
if (index === fromIndex) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const dx = rect.x + rect.width / 2 - fromCentreX;
|
|
34
|
+
const dy = rect.y + rect.height / 2 - fromCentreY;
|
|
35
|
+
const travel = { up: -dy, down: dy, left: -dx, right: dx }[direction];
|
|
36
|
+
if (travel <= MIN_TRAVEL_PX) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const across = isVertical ? Math.abs(dx) : Math.abs(dy);
|
|
40
|
+
const score = travel + across * 2;
|
|
41
|
+
if (score < bestScore) {
|
|
42
|
+
bestScore = score;
|
|
43
|
+
bestIndex = index;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return bestIndex;
|
|
47
|
+
}
|