@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checks `hub-doctor` runs on a game (plan §4.3.7). Each returns
|
|
3
|
+
* findings; an `error` finding fails the run (and the game's CI).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { GamePackageJson, type HubVersion } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import { clientSupport } from '../core/version/hub-version';
|
|
9
|
+
import { isHubUnreachable, type HubUnreachable } from './hub-version-fetch';
|
|
10
|
+
|
|
11
|
+
/** How serious a finding is: `error` fails the run, `warning` and `info` only report. */
|
|
12
|
+
export type FindingLevel = 'error' | 'warning' | 'info';
|
|
13
|
+
|
|
14
|
+
/** One thing the doctor found. */
|
|
15
|
+
export interface Finding {
|
|
16
|
+
/** How serious it is. */
|
|
17
|
+
readonly level: FindingLevel;
|
|
18
|
+
/** Which check found it: `version`, `vendored` or `manifest`. */
|
|
19
|
+
readonly check: 'version' | 'vendored' | 'manifest';
|
|
20
|
+
/** What to tell the developer, with the fix. */
|
|
21
|
+
readonly message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The fix for an old or unsupported client. */
|
|
25
|
+
const UPDATE_FIX = 'run `npm update @diffenderfer-games/hub`';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The installed client against the hub's `/_api/version`: outside
|
|
29
|
+
* `supported` is an error, `deprecated` a warning, `null` (a hub from before
|
|
30
|
+
* versioning) is only reported. An unreachable or slow hub is an error, so a
|
|
31
|
+
* game's CI notices (`--offline` skips the check).
|
|
32
|
+
*/
|
|
33
|
+
export function checkClientVersion(installedVersion: string, hubVersion: HubVersion | null | HubUnreachable): Finding[] {
|
|
34
|
+
if (isHubUnreachable(hubVersion)) {
|
|
35
|
+
return [{ level: 'error', check: 'version', message: `hub unreachable at ${hubVersion.unreachableUrl} (${hubVersion.reason}); check --hub, or pass --offline to skip the version check` }];
|
|
36
|
+
}
|
|
37
|
+
switch (clientSupport(hubVersion, installedVersion)) {
|
|
38
|
+
case 'unsupported':
|
|
39
|
+
return [{ level: 'error', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is not supported by the hub (supported: ${hubVersion?.supported ?? '?'}): ${UPDATE_FIX}` }];
|
|
40
|
+
case 'deprecated':
|
|
41
|
+
return [{ level: 'warning', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is deprecated (deprecated: ${hubVersion?.deprecated ?? '?'}): ${UPDATE_FIX}` }];
|
|
42
|
+
case 'unknown':
|
|
43
|
+
return [{ level: 'info', check: 'version', message: 'the hub predates versioning (no /_api/version); the client version was not checked' }];
|
|
44
|
+
case 'supported':
|
|
45
|
+
return [{ level: 'info', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is supported (hub ${hubVersion?.server ?? '?'})` }];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Leftovers of the v1 vendored client, relative to the game's folder. */
|
|
50
|
+
const VENDORED_LEFTOVERS = [
|
|
51
|
+
'src/hub/hub.ts',
|
|
52
|
+
'src/hub/input.ts',
|
|
53
|
+
'client/src/hub/hub.ts',
|
|
54
|
+
'scripts/sync-hub.mjs',
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
/** A stale vendored `src/hub/` copy or its sync script (the package replaces both). */
|
|
58
|
+
export function checkVendoredCopies(gameDir: string): Finding[] {
|
|
59
|
+
return VENDORED_LEFTOVERS.filter((leftover) => existsSync(join(gameDir, leftover))).map((leftover) => ({
|
|
60
|
+
level: 'warning',
|
|
61
|
+
check: 'vendored',
|
|
62
|
+
message: `${leftover} is a vendored copy of the v1 client: import from '@diffenderfer-games/hub' instead and delete it`,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The `package.json` `game.*` block against the contract's manifest schema. */
|
|
67
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's package.json, parsed by GamePackageJson next
|
|
68
|
+
export function checkManifest(packageJson: unknown): Finding[] {
|
|
69
|
+
const parsed = GamePackageJson.safeParse(packageJson);
|
|
70
|
+
if (parsed.success) {
|
|
71
|
+
return [{ level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` }];
|
|
72
|
+
}
|
|
73
|
+
return parsed.error.issues.map((issue) => ({
|
|
74
|
+
level: 'error',
|
|
75
|
+
check: 'manifest',
|
|
76
|
+
message: `${issue.path.length === 0 ? '(package.json)' : issue.path.join('.')}: ${issue.message}`,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `hub-doctor`: checks a game against the hub (plan §4.3.7). Run it in a
|
|
4
|
+
* game's folder, in CI and before pushing:
|
|
5
|
+
*
|
|
6
|
+
* ```sh
|
|
7
|
+
* npx hub-doctor [--dir <game folder>] [--hub <url>] [--timeout <ms>] [--offline] [--json]
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* - the installed `@diffenderfer-games/hub` against the hub's
|
|
11
|
+
* `/_api/version` (`--hub`, default `https://diffenderfer.games`): outside
|
|
12
|
+
* `supported` fails, `deprecated` warns (`--offline` skips this check);
|
|
13
|
+
* - a stale vendored `src/hub/` copy or `scripts/sync-hub.mjs` warns;
|
|
14
|
+
* - the `package.json` `game.*` block must match the contract's schema.
|
|
15
|
+
*
|
|
16
|
+
* Exits 1 when any check fails.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { parseArgs } from 'node:util';
|
|
21
|
+
import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
|
|
22
|
+
import { checkClientVersion, checkManifest, checkVendoredCopies, type Finding } from './checks';
|
|
23
|
+
import { DEFAULT_HUB_TIMEOUT_MS, fetchHubVersion } from './hub-version-fetch';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_HUB_URL = 'https://diffenderfer.games';
|
|
26
|
+
|
|
27
|
+
/** The command line, parsed. */
|
|
28
|
+
interface DoctorOptions {
|
|
29
|
+
readonly gameDir: string;
|
|
30
|
+
readonly hubUrl: string;
|
|
31
|
+
/** How long to wait for the hub, ms (`--timeout`). */
|
|
32
|
+
readonly timeoutMs: number;
|
|
33
|
+
readonly isOffline: boolean;
|
|
34
|
+
readonly isJson: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readOptions(): DoctorOptions {
|
|
38
|
+
const { values } = parseArgs({
|
|
39
|
+
options: {
|
|
40
|
+
dir: { type: 'string' },
|
|
41
|
+
hub: { type: 'string' },
|
|
42
|
+
timeout: { type: 'string' },
|
|
43
|
+
offline: { type: 'boolean', default: false },
|
|
44
|
+
json: { type: 'boolean', default: false },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const timeoutMs = values.timeout === undefined ? DEFAULT_HUB_TIMEOUT_MS : Number(values.timeout);
|
|
48
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
49
|
+
process.stderr.write('hub-doctor: --timeout must be a positive number of milliseconds\n');
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
return { gameDir: resolve(values.dir ?? '.'), hubUrl: values.hub ?? DEFAULT_HUB_URL, timeoutMs, isOffline: values.offline, isJson: values.json };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a JSON file from disk, parsed by the caller's schema
|
|
56
|
+
function readJsonFile(path: string): unknown {
|
|
57
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON.parse's result, parsed by the caller's schema
|
|
58
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The client version the game has installed (its `node_modules` copy), else this doctor's own. */
|
|
63
|
+
function installedClientVersion(gameDir: string): string {
|
|
64
|
+
try {
|
|
65
|
+
const installed = readJsonFile(join(gameDir, 'node_modules', '@diffenderfer-games', 'hub', 'package.json'));
|
|
66
|
+
if (typeof installed === 'object' && installed !== null && 'version' in installed && typeof installed.version === 'string') {
|
|
67
|
+
return installed.version;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Not installed in the game: report this doctor's own version.
|
|
71
|
+
}
|
|
72
|
+
return HUB_CLIENT_VERSION;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runDoctor(options: DoctorOptions): Promise<Finding[]> {
|
|
76
|
+
const findings: Finding[] = [];
|
|
77
|
+
if (!options.isOffline) {
|
|
78
|
+
findings.push(...checkClientVersion(installedClientVersion(options.gameDir), await fetchHubVersion(options.hubUrl, options.timeoutMs)));
|
|
79
|
+
}
|
|
80
|
+
findings.push(...checkVendoredCopies(options.gameDir));
|
|
81
|
+
try {
|
|
82
|
+
findings.push(...checkManifest(readJsonFile(join(options.gameDir, 'package.json'))));
|
|
83
|
+
} catch (failure) {
|
|
84
|
+
findings.push({ level: 'error', check: 'manifest', message: `could not read package.json: ${failure instanceof Error ? failure.message : String(failure)}` });
|
|
85
|
+
}
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const options = readOptions();
|
|
90
|
+
const findings = await runDoctor(options);
|
|
91
|
+
if (options.isJson) {
|
|
92
|
+
process.stdout.write(`${JSON.stringify({ findings }, null, 2)}\n`);
|
|
93
|
+
} else {
|
|
94
|
+
for (const finding of findings) {
|
|
95
|
+
process.stdout.write(`${finding.level.padEnd(7)} ${finding.check}: ${finding.message}\n`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
process.exitCode = findings.some((finding) => finding.level === 'error') ? 1 : 0;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the hub's `GET /_api/version` for `hub-doctor`, with a timeout so a
|
|
3
|
+
* hung or unreachable hub fails a game's CI quickly instead of stalling it.
|
|
4
|
+
*/
|
|
5
|
+
import { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
6
|
+
|
|
7
|
+
/** How long the doctor waits for the hub by default, ms. */
|
|
8
|
+
export const DEFAULT_HUB_TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
/** The hub could not be asked: down, slow, or answering with an error. */
|
|
11
|
+
export interface HubUnreachable {
|
|
12
|
+
/** The URL that was asked. */
|
|
13
|
+
readonly unreachableUrl: string;
|
|
14
|
+
/** Why, for the message (`timed out after 10000 ms`, `HTTP 502`, a network error). */
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a version lookup failed. */
|
|
19
|
+
export function isHubUnreachable(lookup: HubVersion | null | HubUnreachable): lookup is HubUnreachable {
|
|
20
|
+
return lookup !== null && 'unreachableUrl' in lookup;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The hub's version: `null` for a hub from before versioning (404), a
|
|
25
|
+
* {@link HubUnreachable} when it can't be read within `timeoutMs`.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchHubVersion(hubUrl: string, timeoutMs: number = DEFAULT_HUB_TIMEOUT_MS): Promise<HubVersion | null | HubUnreachable> {
|
|
28
|
+
const url = new URL('/_api/version', hubUrl).toString();
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
31
|
+
if (response.status === 404) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
return { unreachableUrl: url, reason: `HTTP ${String(response.status)}` };
|
|
36
|
+
}
|
|
37
|
+
return HubVersion.parse(await response.json());
|
|
38
|
+
} catch (failure) {
|
|
39
|
+
const isTimeout = failure instanceof Error && (failure.name === 'TimeoutError' || failure.name === 'AbortError');
|
|
40
|
+
const reason = isTimeout ? `timed out after ${String(timeoutMs)} ms` : failure instanceof Error ? failure.message : String(failure);
|
|
41
|
+
return { unreachableUrl: url, reason };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/globals.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-wide singletons across client versions (plan §4.4). One page often
|
|
3
|
+
* runs two client builds: the game's bundled copy and the host's injected
|
|
4
|
+
* `/_hub/hub.js`. The input system, daily challenges and the realtime API
|
|
5
|
+
* must exist once per page, so each is registered here with the version that
|
|
6
|
+
* created it and a small `api` number that changes only when the
|
|
7
|
+
* cross-bundle shape does.
|
|
8
|
+
*
|
|
9
|
+
* - Same `api`: the first creator wins and everyone shares it.
|
|
10
|
+
* - Different `api`: the caller decides who owns the page (see
|
|
11
|
+
* {@link claimSingleton}'s `onMismatch`); the mismatch is logged once with the fix.
|
|
12
|
+
* - The v1 names (`window.__HUB_INPUT__`, `__HUB_DAILY__`, `__HUB_RT__`) stay as
|
|
13
|
+
* aliases while vendored v1 copies are still around.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A page-wide singleton's name. */
|
|
17
|
+
export type SingletonName = 'input' | 'daily' | 'rt';
|
|
18
|
+
|
|
19
|
+
/** One registered singleton. */
|
|
20
|
+
export interface SingletonRecord {
|
|
21
|
+
/** The client version that created it. */
|
|
22
|
+
readonly version: string;
|
|
23
|
+
/** Its cross-bundle shape number. */
|
|
24
|
+
readonly api: number;
|
|
25
|
+
/** The instance itself; another bundle's object, so callers check it before use. */
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle on the page
|
|
27
|
+
readonly instance: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The registry on `window.__DG__`. */
|
|
31
|
+
export type DgGlobals = { [K in SingletonName]?: SingletonRecord };
|
|
32
|
+
|
|
33
|
+
/** The version recorded for a singleton adopted from a v1 (vendored) client, which predates the registry. */
|
|
34
|
+
export const V1_CLIENT_VERSION = 'v1';
|
|
35
|
+
|
|
36
|
+
/** The v1 window names, kept as aliases. */
|
|
37
|
+
export const LEGACY_GLOBAL_NAMES = { input: '__HUB_INPUT__', daily: '__HUB_DAILY__', rt: '__HUB_RT__' } as const satisfies Record<SingletonName, string>;
|
|
38
|
+
|
|
39
|
+
declare global {
|
|
40
|
+
interface Window {
|
|
41
|
+
__DG__?: DgGlobals;
|
|
42
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
|
|
43
|
+
__HUB_INPUT__?: unknown;
|
|
44
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
|
|
45
|
+
__HUB_DAILY__?: unknown;
|
|
46
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
|
|
47
|
+
__HUB_RT__?: unknown;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How to claim a singleton. */
|
|
52
|
+
export interface SingletonClaim<T> {
|
|
53
|
+
readonly name: SingletonName;
|
|
54
|
+
/** This bundle's client version. */
|
|
55
|
+
readonly version: string;
|
|
56
|
+
/** This bundle's shape number for the singleton. */
|
|
57
|
+
readonly api: number;
|
|
58
|
+
/** Make the instance when nobody has yet. */
|
|
59
|
+
readonly create: () => T;
|
|
60
|
+
/** Whether a registered instance is usable as `T` (it may come from another bundle). */
|
|
61
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: checks another bundle's object
|
|
62
|
+
readonly isInstance: (candidate: unknown) => candidate is T;
|
|
63
|
+
/**
|
|
64
|
+
* The registered one has a different `api`: return `true` to replace it
|
|
65
|
+
* with this bundle's instance, `false` to keep it (then `create` makes a
|
|
66
|
+
* private instance for this bundle only). Default: keep it.
|
|
67
|
+
*/
|
|
68
|
+
readonly onMismatch?: (registered: SingletonRecord) => boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const warnedMismatches = new Set<SingletonName>();
|
|
72
|
+
|
|
73
|
+
/** The page registry, created on first use (`undefined` outside a browser). */
|
|
74
|
+
function registryOf(page: Window | undefined): DgGlobals | undefined {
|
|
75
|
+
if (page === undefined) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
page.__DG__ ??= {};
|
|
79
|
+
return page.__DG__;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Register (or join) a page-wide singleton and return the instance this bundle should use. */
|
|
83
|
+
export function claimSingleton<T>(claim: SingletonClaim<T>): T {
|
|
84
|
+
const page = typeof window === 'undefined' ? undefined : window;
|
|
85
|
+
const registry = registryOf(page);
|
|
86
|
+
if (page === undefined || registry === undefined) {
|
|
87
|
+
return claim.create();
|
|
88
|
+
}
|
|
89
|
+
const registered = registry[claim.name];
|
|
90
|
+
if (registered !== undefined && claim.isInstance(registered.instance)) {
|
|
91
|
+
if (registered.api === claim.api) {
|
|
92
|
+
return registered.instance;
|
|
93
|
+
}
|
|
94
|
+
if (!warnedMismatches.has(claim.name)) {
|
|
95
|
+
warnedMismatches.add(claim.name);
|
|
96
|
+
// eslint-disable-next-line no-console -- the plan's one-time warning to the game developer, with the fix
|
|
97
|
+
console.warn(`[hub] two clients on this page disagree on "${claim.name}" (v${registered.version} api ${String(registered.api)}, v${claim.version} api ${String(claim.api)}). Fix: npm update @diffenderfer-games/hub`);
|
|
98
|
+
}
|
|
99
|
+
if (claim.onMismatch?.(registered) !== true) {
|
|
100
|
+
return claim.create();
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
const legacy = page[LEGACY_GLOBAL_NAMES[claim.name]];
|
|
104
|
+
if (registered === undefined && claim.isInstance(legacy)) {
|
|
105
|
+
// A v1 vendored copy got there first: share it, as v1 bundles did, and
|
|
106
|
+
// record it so later bundles (and diagnostics) see which client owns it.
|
|
107
|
+
registry[claim.name] = { version: V1_CLIENT_VERSION, api: claim.api, instance: legacy };
|
|
108
|
+
return legacy;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const instance = claim.create();
|
|
112
|
+
registry[claim.name] = { version: claim.version, api: claim.api, instance };
|
|
113
|
+
page[LEGACY_GLOBAL_NAMES[claim.name]] = instance;
|
|
114
|
+
return instance;
|
|
115
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub`: the hub client for games on diffenderfer.games.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { hub } from '@diffenderfer-games/hub';
|
|
6
|
+
*
|
|
7
|
+
* const { user } = await hub.me();
|
|
8
|
+
* await hub.putSave('auto', { level: 7, gold: 120 });
|
|
9
|
+
* await hub.submitScore('highscore', 9001, { title: 'High Score' });
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* In a hosted game the slug and API base are detected from the page; use
|
|
13
|
+
* {@link createHub} for others. Every call rejects with a {@link HubError}
|
|
14
|
+
* when it fails, except the fire-and-forget analytics (`recordPlay`,
|
|
15
|
+
* `heartbeat`, `event`). When the hub can't be reached, reads come from an
|
|
16
|
+
* offline cache and writes are queued until it is back.
|
|
17
|
+
*
|
|
18
|
+
* The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
|
|
19
|
+
* `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
|
|
20
|
+
* its own entry (`@diffenderfer-games/hub/input`, ...) for games that want
|
|
21
|
+
* only that part.
|
|
22
|
+
*
|
|
23
|
+
* @packageDocumentation
|
|
24
|
+
*/
|
|
25
|
+
export { createHub, Hub, hub } from './core/hub';
|
|
26
|
+
export {
|
|
27
|
+
ContentRejectedError,
|
|
28
|
+
HubError,
|
|
29
|
+
OfflineError,
|
|
30
|
+
UNSUPPORTED_BY_HUB,
|
|
31
|
+
type HubErrorCode,
|
|
32
|
+
type HubErrorDetails,
|
|
33
|
+
} from './core/errors';
|
|
34
|
+
export type { SyncConflict, SyncResult } from './core/connection';
|
|
35
|
+
export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progress';
|
|
36
|
+
export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
|
|
37
|
+
export type { HubMenuControl } from './core/menu-control';
|
|
38
|
+
export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
|
|
39
|
+
export type { BufferedEvent } from './core/offline/events';
|
|
40
|
+
export type { EndpointFields, EndpointName, EndpointReply } from './core/rest/endpoint-types';
|
|
41
|
+
export { CLIENT_VERSION_HEADER } from './core/rest/rest-client';
|
|
42
|
+
export type { ClientSupport } from './core/version/hub-version';
|
|
43
|
+
export { HUB_CLIENT_VERSION } from './core/version/client-version.gen';
|
|
44
|
+
export type * from './core/types';
|
|
45
|
+
export * from './daily';
|
|
46
|
+
export * from './input';
|
|
47
|
+
export type * from './social';
|
|
48
|
+
export type * from './mp';
|
|
49
|
+
export type * from './race';
|
|
50
|
+
export type * from './rooms';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The raw state of every input device, updated by DOM events and sampled once
|
|
3
|
+
* per frame: held keys and mouse buttons, the pointer, movement and wheel
|
|
4
|
+
* accumulators, one-frame pulses, on-screen sticks and buttons, and gamepads.
|
|
5
|
+
*/
|
|
6
|
+
import { AXIS_DEADZONE, readGamepads } from './gamepad';
|
|
7
|
+
|
|
8
|
+
/** An on-screen stick's output, -1..1 per axis. */
|
|
9
|
+
export interface StickOutput {
|
|
10
|
+
outX: number;
|
|
11
|
+
outY: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A 2D accumulator. */
|
|
15
|
+
interface Delta {
|
|
16
|
+
dx: number;
|
|
17
|
+
dy: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Keys that scroll the page; cancelled during play (not while typing). */
|
|
21
|
+
const SCROLL_KEYS = new Set([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright']);
|
|
22
|
+
|
|
23
|
+
/** Input types that don't take text (buttons, checkboxes, sliders…). */
|
|
24
|
+
const NON_TEXT_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'checkbox', 'radio', 'range', 'color', 'file', 'image']);
|
|
25
|
+
|
|
26
|
+
/** Whether typing in `target` owns Space and the arrow keys. */
|
|
27
|
+
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
28
|
+
if (!(target instanceof HTMLElement)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (target.isContentEditable || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return target instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(target.type);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the device state reports back to the input system. */
|
|
38
|
+
export interface DeviceStateEvents {
|
|
39
|
+
/** The mouse moved or clicked: it is the active pointer. */
|
|
40
|
+
onMouseActivity(): void;
|
|
41
|
+
/** A gamepad connected (`true`) or disconnected (`false`). */
|
|
42
|
+
onGamepadConnection(isConnected: boolean): void;
|
|
43
|
+
/** Whether keydown should cancel scrolling keys (a game defined its inputs). */
|
|
44
|
+
shouldCancelScrollKeys(): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Raw device state for one page. */
|
|
48
|
+
export class DeviceState {
|
|
49
|
+
readonly heldKeys = new Set<string>();
|
|
50
|
+
readonly heldMouseButtons = new Set<number>();
|
|
51
|
+
readonly pressedVirtualButtons = new Set<string>();
|
|
52
|
+
readonly sticks = new Map<string, StickOutput>();
|
|
53
|
+
pointerX = 0;
|
|
54
|
+
pointerY = 0;
|
|
55
|
+
readonly movement: Delta = { dx: 0, dy: 0 };
|
|
56
|
+
readonly wheel: Delta = { dx: 0, dy: 0 };
|
|
57
|
+
gamepads: Gamepad[] = [];
|
|
58
|
+
frame = 0;
|
|
59
|
+
private readonly pulseFrames = new Map<string, number>();
|
|
60
|
+
|
|
61
|
+
/** Fire a one-frame pulse (`tap`, `mouse:dbl`). */
|
|
62
|
+
pulse(name: string): void {
|
|
63
|
+
this.pulseFrames.set(name, this.frame);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether a pulse fired this frame or the last. */
|
|
67
|
+
isPulseActive(name: string): boolean {
|
|
68
|
+
const firedAt = this.pulseFrames.get(name);
|
|
69
|
+
return firedAt === this.frame || firedAt === this.frame - 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Poll the gamepads. Returns whether any are connected and whether any moved. */
|
|
73
|
+
pollGamepads(): { isConnected: boolean; hasActivity: boolean } {
|
|
74
|
+
this.gamepads = readGamepads();
|
|
75
|
+
const hasActivity = this.gamepads.some((pad) => pad.buttons.some((button) => button.pressed || button.value > 0.5)
|
|
76
|
+
|| pad.axes.some((axis) => Math.abs(axis) > AXIS_DEADZONE));
|
|
77
|
+
return { isConnected: this.gamepads.length > 0, hasActivity };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** End-of-frame decay of the relative accumulators. */
|
|
81
|
+
decay(): void {
|
|
82
|
+
this.movement.dx = 0;
|
|
83
|
+
this.movement.dy = 0;
|
|
84
|
+
this.wheel.dx *= 0.6;
|
|
85
|
+
this.wheel.dy *= 0.6;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Release everything held (focus loss, hidden tab), so nothing sticks. */
|
|
89
|
+
releaseAll(): void {
|
|
90
|
+
this.heldKeys.clear();
|
|
91
|
+
this.heldMouseButtons.clear();
|
|
92
|
+
this.pressedVirtualButtons.clear();
|
|
93
|
+
for (const stick of this.sticks.values()) {
|
|
94
|
+
stick.outX = 0;
|
|
95
|
+
stick.outY = 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Wire the DOM listeners that feed this state. */
|
|
100
|
+
listen(events: DeviceStateEvents): void {
|
|
101
|
+
if (typeof window === 'undefined') {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
window.addEventListener('keydown', (event) => {
|
|
105
|
+
const key = event.key.toLowerCase();
|
|
106
|
+
this.heldKeys.add(key);
|
|
107
|
+
if (events.shouldCancelScrollKeys() && SCROLL_KEYS.has(key) && !isTypingTarget(event.target)) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
window.addEventListener('keyup', (event) => this.heldKeys.delete(event.key.toLowerCase()));
|
|
112
|
+
window.addEventListener('mousemove', (event) => {
|
|
113
|
+
this.movement.dx += event.movementX;
|
|
114
|
+
this.movement.dy += event.movementY;
|
|
115
|
+
this.pointerX = event.clientX;
|
|
116
|
+
this.pointerY = event.clientY;
|
|
117
|
+
events.onMouseActivity();
|
|
118
|
+
});
|
|
119
|
+
window.addEventListener('mousedown', (event) => {
|
|
120
|
+
this.heldMouseButtons.add(event.button);
|
|
121
|
+
events.onMouseActivity();
|
|
122
|
+
});
|
|
123
|
+
window.addEventListener('mouseup', (event) => this.heldMouseButtons.delete(event.button));
|
|
124
|
+
window.addEventListener('dblclick', () => {
|
|
125
|
+
this.pulse('mouse:dbl');
|
|
126
|
+
});
|
|
127
|
+
window.addEventListener('wheel', (event) => {
|
|
128
|
+
this.wheel.dx += Math.sign(event.deltaX);
|
|
129
|
+
this.wheel.dy += Math.sign(event.deltaY);
|
|
130
|
+
}, { passive: true });
|
|
131
|
+
window.addEventListener('blur', () => {
|
|
132
|
+
this.releaseAll();
|
|
133
|
+
});
|
|
134
|
+
document.addEventListener('visibilitychange', () => {
|
|
135
|
+
if (document.hidden) {
|
|
136
|
+
this.releaseAll();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
window.addEventListener('gamepadconnected', () => {
|
|
140
|
+
events.onGamepadConnection(true);
|
|
141
|
+
});
|
|
142
|
+
window.addEventListener('gamepaddisconnected', () => {
|
|
143
|
+
events.onGamepadConnection(false);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge detection for polled buttons: a plain rising edge, and a rising edge
|
|
3
|
+
* that repeats while held (menu navigation: a first step, a pause, then steady
|
|
4
|
+
* steps).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Delay before a held direction starts repeating. */
|
|
8
|
+
const FIRST_REPEAT_DELAY_MS = 320;
|
|
9
|
+
/** Interval between repeats while held. */
|
|
10
|
+
const REPEAT_INTERVAL_MS = 130;
|
|
11
|
+
|
|
12
|
+
/** A monotonic clock in ms. */
|
|
13
|
+
export function nowMs(): number {
|
|
14
|
+
return typeof performance === 'undefined' ? Date.now() : performance.now();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Rising edges of named polled buttons. */
|
|
18
|
+
export class RisingEdges {
|
|
19
|
+
private readonly wasPressed = new Map<string, boolean>();
|
|
20
|
+
|
|
21
|
+
/** Whether `key` went from released to pressed since the last call for it. */
|
|
22
|
+
rose(key: string, isPressed: boolean): boolean {
|
|
23
|
+
const wasPressedBefore = this.wasPressed.get(key) === true;
|
|
24
|
+
this.wasPressed.set(key, isPressed);
|
|
25
|
+
return isPressed && !wasPressedBefore;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rising edges that repeat while held. */
|
|
30
|
+
export class RepeatingEdges {
|
|
31
|
+
private readonly timers = new Map<string, { isHeld: boolean; nextAt: number }>();
|
|
32
|
+
|
|
33
|
+
/** Whether `key` fires now: on the press, then after a delay, then steadily while held. */
|
|
34
|
+
fires(key: string, isPressed: boolean, at: number = nowMs()): boolean {
|
|
35
|
+
let timer = this.timers.get(key);
|
|
36
|
+
if (timer === undefined) {
|
|
37
|
+
timer = { isHeld: false, nextAt: 0 };
|
|
38
|
+
this.timers.set(key, timer);
|
|
39
|
+
}
|
|
40
|
+
if (!isPressed) {
|
|
41
|
+
timer.isHeld = false;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (!timer.isHeld) {
|
|
45
|
+
timer.isHeld = true;
|
|
46
|
+
timer.nextAt = at + FIRST_REPEAT_DELAY_MS;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (at >= timer.nextAt) {
|
|
50
|
+
timer.nextAt = at + REPEAT_INTERVAL_MS;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading standard gamepads: button indices, the stick deadzone and the
|
|
3
|
+
* directions a pad is pointing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Standard-gamepad button indices. */
|
|
7
|
+
export const PAD_BUTTON = {
|
|
8
|
+
A: 0, B: 1, X: 2, Y: 3, LB: 4, RB: 5, LT: 6, RT: 7, SELECT: 8, START: 9, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
/** Stick deflection below this is ignored. */
|
|
12
|
+
export const AXIS_DEADZONE = 0.35;
|
|
13
|
+
|
|
14
|
+
/** A screen direction. */
|
|
15
|
+
export type Direction = 'up' | 'down' | 'left' | 'right';
|
|
16
|
+
|
|
17
|
+
/** Which directions are held. */
|
|
18
|
+
export type HeldDirections = Record<Direction, boolean>;
|
|
19
|
+
|
|
20
|
+
/** The connected gamepads (the browser's list has holes). */
|
|
21
|
+
export function readGamepads(): Gamepad[] {
|
|
22
|
+
if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function') {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return navigator.getGamepads().filter((pad): pad is Gamepad => pad !== null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether `pad` holds button `index`. */
|
|
29
|
+
export function isPadButtonPressed(pad: Gamepad, index: number): boolean {
|
|
30
|
+
return pad.buttons[index]?.pressed === true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether any pad holds button `index`. */
|
|
34
|
+
export function isAnyPadButtonPressed(pads: readonly Gamepad[], index: number): boolean {
|
|
35
|
+
return pads.some((pad) => isPadButtonPressed(pad, index));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The directions one pad points (left stick past `deadzone`, or the d-pad). */
|
|
39
|
+
export function padDirections(pad: Gamepad, deadzone: number): HeldDirections {
|
|
40
|
+
const horizontal = pad.axes[0] ?? 0;
|
|
41
|
+
const vertical = pad.axes[1] ?? 0;
|
|
42
|
+
return {
|
|
43
|
+
up: vertical < -deadzone || isPadButtonPressed(pad, PAD_BUTTON.UP),
|
|
44
|
+
down: vertical > deadzone || isPadButtonPressed(pad, PAD_BUTTON.DOWN),
|
|
45
|
+
left: horizontal < -deadzone || isPadButtonPressed(pad, PAD_BUTTON.LEFT),
|
|
46
|
+
right: horizontal > deadzone || isPadButtonPressed(pad, PAD_BUTTON.RIGHT),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The directions any pad points. */
|
|
51
|
+
export function anyPadDirections(pads: readonly Gamepad[], deadzone: number): HeldDirections {
|
|
52
|
+
const held: HeldDirections = { up: false, down: false, left: false, right: false };
|
|
53
|
+
for (const pad of pads) {
|
|
54
|
+
const directions = padDirections(pad, deadzone);
|
|
55
|
+
held.up ||= directions.up;
|
|
56
|
+
held.down ||= directions.down;
|
|
57
|
+
held.left ||= directions.left;
|
|
58
|
+
held.right ||= directions.right;
|
|
59
|
+
}
|
|
60
|
+
return held;
|
|
61
|
+
}
|