@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/LICENSE +21 -21
- package/README.md +203 -139
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +119 -28
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +9 -42
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +90 -9
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2354 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +125 -89
- package/src/auto.ts +39 -39
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +67 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +320 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +93 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +63 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +408 -299
- package/src/core/errors.ts +160 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +269 -205
- package/src/core/hub.ts +352 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +304 -261
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +126 -171
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +87 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +163 -152
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/storage/json-guards.ts +80 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +276 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +105 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -76
- package/src/doctor/cli.ts +101 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/doctor/world-check.ts +61 -0
- package/src/globals.ts +115 -110
- package/src/index.ts +68 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/focus-ring.ts +13 -0
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +171 -164
- package/src/input/input-system.ts +406 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +200 -191
- package/src/input/navigator.ts +182 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +164 -177
- package/src/input.ts +26 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +234 -208
- package/src/overlay/styles.ts +13 -12
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +126 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +40 -28
- package/src/realtime/multiplayer-types.ts +66 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +151 -125
- package/src/realtime/rooms-forwarder.ts +104 -79
- package/src/realtime/runtime-api.ts +74 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +77 -72
- package/src/realtime/social-types.ts +86 -78
- package/src/realtime/types.ts +54 -53
- package/src/realtime/wire-types.ts +79 -72
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +10 -10
- package/src/social.ts +19 -18
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `game.controls` (`window.__HUB__.controls`): the gamepad-to-keyboard map a
|
|
3
|
-
* zero-config game declares. It comes from the page, so it is read field by
|
|
4
|
-
* field; anything malformed falls back to the defaults.
|
|
5
|
-
*/
|
|
6
|
-
import { isObject, readField } from '../realtime/cross-bundle';
|
|
7
|
-
|
|
8
|
-
/** One player's resolved map: a `KeyboardEvent.code` per direction and per button index. */
|
|
9
|
-
export interface ResolvedControlMap {
|
|
10
|
-
up: string;
|
|
11
|
-
down: string;
|
|
12
|
-
left: string;
|
|
13
|
-
right: string;
|
|
14
|
-
buttons: Map<number, string>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** The parsed `game.controls`: one map per player (pads past the end use the first). */
|
|
18
|
-
export type LegacyControls = readonly ResolvedControlMap[];
|
|
19
|
-
|
|
20
|
-
/** The defaults: arrows, A=Space, B=Enter, X=Z, Y=X, Select=Escape, Start=Enter. */
|
|
21
|
-
const DEFAULT_BUTTONS: ReadonlyArray<readonly [number, string]> = [[0, 'Space'], [1, 'Enter'], [2, 'KeyZ'], [3, 'KeyX'], [9, 'Enter'], [8, 'Escape']];
|
|
22
|
-
|
|
23
|
-
/** A text field of the map, or the default. */
|
|
24
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
25
|
-
function textOr(map: unknown, key: string, fallback: string): string {
|
|
26
|
-
const field = readField(map, key);
|
|
27
|
-
return typeof field === 'string' && field !== '' ? field : fallback;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** One player's map over the defaults. */
|
|
31
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
32
|
-
function resolveMap(map: unknown): ResolvedControlMap {
|
|
33
|
-
const buttons = new Map<number, string>(DEFAULT_BUTTONS);
|
|
34
|
-
const declared = readField(map, 'buttons');
|
|
35
|
-
if (isObject(declared)) {
|
|
36
|
-
for (const [index, code] of Object.entries(declared)) {
|
|
37
|
-
if (typeof code === 'string' && code !== '' && /^\d+$/.test(index)) {
|
|
38
|
-
buttons.set(Number(index), code);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return {
|
|
43
|
-
up: textOr(map, 'up', 'ArrowUp'),
|
|
44
|
-
down: textOr(map, 'down', 'ArrowDown'),
|
|
45
|
-
left: textOr(map, 'left', 'ArrowLeft'),
|
|
46
|
-
right: textOr(map, 'right', 'ArrowRight'),
|
|
47
|
-
buttons,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Parse `game.controls` (a shared map, or per-player maps in `players`). */
|
|
52
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
53
|
-
export function parseLegacyControls(raw: unknown): LegacyControls {
|
|
54
|
-
const players = readField(raw, 'players');
|
|
55
|
-
if (Array.isArray(players) && players.length > 0) {
|
|
56
|
-
return players.map(resolveMap);
|
|
57
|
-
}
|
|
58
|
-
return [resolveMap(raw)];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** The map for the pad at `padIndex`. */
|
|
62
|
-
export function controlMapFor(controls: LegacyControls, padIndex: number): ResolvedControlMap {
|
|
63
|
-
return controls[padIndex] ?? controls[0] ?? resolveMap(null);
|
|
64
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `game.controls` (`window.__HUB__.controls`): the gamepad-to-keyboard map a
|
|
3
|
+
* zero-config game declares. It comes from the page, so it is read field by
|
|
4
|
+
* field; anything malformed falls back to the defaults.
|
|
5
|
+
*/
|
|
6
|
+
import { isObject, readField } from '../realtime/cross-bundle';
|
|
7
|
+
|
|
8
|
+
/** One player's resolved map: a `KeyboardEvent.code` per direction and per button index. */
|
|
9
|
+
export interface ResolvedControlMap {
|
|
10
|
+
up: string;
|
|
11
|
+
down: string;
|
|
12
|
+
left: string;
|
|
13
|
+
right: string;
|
|
14
|
+
buttons: Map<number, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The parsed `game.controls`: one map per player (pads past the end use the first). */
|
|
18
|
+
export type LegacyControls = readonly ResolvedControlMap[];
|
|
19
|
+
|
|
20
|
+
/** The defaults: arrows, A=Space, B=Enter, X=Z, Y=X, Select=Escape, Start=Enter. */
|
|
21
|
+
const DEFAULT_BUTTONS: ReadonlyArray<readonly [number, string]> = [[0, 'Space'], [1, 'Enter'], [2, 'KeyZ'], [3, 'KeyX'], [9, 'Enter'], [8, 'Escape']];
|
|
22
|
+
|
|
23
|
+
/** A text field of the map, or the default. */
|
|
24
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
25
|
+
function textOr(map: unknown, key: string, fallback: string): string {
|
|
26
|
+
const field = readField(map, key);
|
|
27
|
+
return typeof field === 'string' && field !== '' ? field : fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One player's map over the defaults. */
|
|
31
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
32
|
+
function resolveMap(map: unknown): ResolvedControlMap {
|
|
33
|
+
const buttons = new Map<number, string>(DEFAULT_BUTTONS);
|
|
34
|
+
const declared = readField(map, 'buttons');
|
|
35
|
+
if (isObject(declared)) {
|
|
36
|
+
for (const [index, code] of Object.entries(declared)) {
|
|
37
|
+
if (typeof code === 'string' && code !== '' && /^\d+$/.test(index)) {
|
|
38
|
+
buttons.set(Number(index), code);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
up: textOr(map, 'up', 'ArrowUp'),
|
|
44
|
+
down: textOr(map, 'down', 'ArrowDown'),
|
|
45
|
+
left: textOr(map, 'left', 'ArrowLeft'),
|
|
46
|
+
right: textOr(map, 'right', 'ArrowRight'),
|
|
47
|
+
buttons,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse `game.controls` (a shared map, or per-player maps in `players`). */
|
|
52
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
53
|
+
export function parseLegacyControls(raw: unknown): LegacyControls {
|
|
54
|
+
const players = readField(raw, 'players');
|
|
55
|
+
if (Array.isArray(players) && players.length > 0) {
|
|
56
|
+
return players.map(resolveMap);
|
|
57
|
+
}
|
|
58
|
+
return [resolveMap(raw)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The map for the pad at `padIndex`. */
|
|
62
|
+
export function controlMapFor(controls: LegacyControls, padIndex: number): ResolvedControlMap {
|
|
63
|
+
return controls[padIndex] ?? controls[0] ?? resolveMap(null);
|
|
64
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which kind of input the player used last, published as `data-hub-input` on
|
|
3
|
+
* `<html>` so focus styles show only to keyboard and gamepad players: a tap or
|
|
4
|
+
* click never leaves a ring behind, and touch devices show none at all. The
|
|
5
|
+
* attribute is the shared state, so every bundle's copy of the client agrees.
|
|
6
|
+
*/
|
|
7
|
+
import { isTypingTarget } from './device-state';
|
|
8
|
+
import { FOCUS_RING_SHADOW } from './focus-ring';
|
|
9
|
+
|
|
10
|
+
/** `keys`: keyboard or gamepad. `pointer`: mouse or pen. `touch`: a finger. */
|
|
11
|
+
export type InputModality = 'keys' | 'pointer' | 'touch';
|
|
12
|
+
|
|
13
|
+
/** The `<html>` attribute that holds the modality. */
|
|
14
|
+
export const MODALITY_ATTRIBUTE = 'data-hub-input';
|
|
15
|
+
|
|
16
|
+
const STYLE_ID = 'hub-focus-style';
|
|
17
|
+
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'AltGraph', 'CapsLock']);
|
|
18
|
+
const MODALITIES: readonly InputModality[] = ['keys', 'pointer', 'touch'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The page focus styles: the gamepad highlight (`gp-focus`) for keyboard and
|
|
22
|
+
* gamepad players only, and no browser focus ring on buttons after a tap.
|
|
23
|
+
*/
|
|
24
|
+
const PAGE_FOCUS_CSS = `html[${MODALITY_ATTRIBUTE}="keys"] .gp-focus{outline:none !important;box-shadow:${FOCUS_RING_SHADOW} !important;}
|
|
25
|
+
html[${MODALITY_ATTRIBUTE}="touch"] :is(button,a,summary,[role="button"],[tabindex]):focus{outline:none;}`;
|
|
26
|
+
|
|
27
|
+
/** The modality a pointer event's `pointerType` implies. */
|
|
28
|
+
export function modalityOfPointer(pointerType: string): InputModality {
|
|
29
|
+
return pointerType === 'touch' ? 'touch' : 'pointer';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The modality a key press implies, or `null` when it says nothing (a lone modifier, typing in a field). */
|
|
33
|
+
export function modalityOfKey(key: string, isTyping: boolean): InputModality | null {
|
|
34
|
+
if (MODIFIER_KEYS.has(key)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Typing (an on-screen keyboard fires keydown too) is not navigating; Tab always is.
|
|
38
|
+
return isTyping && key !== 'Tab' ? null : 'keys';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the modality is stored: `<html>`, or a fake in tests. */
|
|
42
|
+
export interface ModalityRoot {
|
|
43
|
+
getAttribute(name: string): string | null;
|
|
44
|
+
setAttribute(name: string, value: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads and writes the page's modality attribute. */
|
|
48
|
+
export class ModalityTracker {
|
|
49
|
+
private readonly root: ModalityRoot;
|
|
50
|
+
|
|
51
|
+
/** Start at `initial` unless another copy already set the attribute. */
|
|
52
|
+
constructor(root: ModalityRoot, initial: InputModality) {
|
|
53
|
+
this.root = root;
|
|
54
|
+
if (this.readAttribute() === null) {
|
|
55
|
+
this.set(initial);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The last modality used. */
|
|
60
|
+
get current(): InputModality {
|
|
61
|
+
return this.readAttribute() ?? 'pointer';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether focus should show: the last input was a keyboard or gamepad. */
|
|
65
|
+
get showsFocus(): boolean {
|
|
66
|
+
return this.current === 'keys';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Record the modality the player just used. */
|
|
70
|
+
set(modality: InputModality): void {
|
|
71
|
+
if (this.readAttribute() !== modality) {
|
|
72
|
+
this.root.setAttribute(MODALITY_ATTRIBUTE, modality);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A key went down (`isTyping`: in a text field). */
|
|
77
|
+
noteKey(key: string, isTyping: boolean): void {
|
|
78
|
+
const modality = modalityOfKey(key, isTyping);
|
|
79
|
+
if (modality !== null) {
|
|
80
|
+
this.set(modality);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A pointer went down. */
|
|
85
|
+
notePointer(pointerType: string): void {
|
|
86
|
+
this.set(modalityOfPointer(pointerType));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private readAttribute(): InputModality | null {
|
|
90
|
+
const value = this.root.getAttribute(MODALITY_ATTRIBUTE);
|
|
91
|
+
return MODALITIES.find((modality) => modality === value) ?? null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let pageTracker: ModalityTracker | null = null;
|
|
96
|
+
|
|
97
|
+
/** Track the page's modality (once per bundle) and add the focus styles (once per page). */
|
|
98
|
+
export function trackInputModality(): ModalityTracker {
|
|
99
|
+
if (pageTracker !== null) {
|
|
100
|
+
return pageTracker;
|
|
101
|
+
}
|
|
102
|
+
const root = document.documentElement;
|
|
103
|
+
const isTouchDevice = typeof matchMedia === 'function' && matchMedia('(hover: none) and (pointer: coarse)').matches;
|
|
104
|
+
const isFirstCopy = root.getAttribute(MODALITY_ATTRIBUTE) === null;
|
|
105
|
+
const tracker = new ModalityTracker(root, isTouchDevice ? 'touch' : 'pointer');
|
|
106
|
+
pageTracker = tracker;
|
|
107
|
+
if (isFirstCopy) {
|
|
108
|
+
document.addEventListener('keydown', (event) => {
|
|
109
|
+
tracker.noteKey(event.key, isTypingTarget(event.target));
|
|
110
|
+
}, true);
|
|
111
|
+
document.addEventListener('pointerdown', (event) => {
|
|
112
|
+
tracker.notePointer(event.pointerType);
|
|
113
|
+
}, true);
|
|
114
|
+
}
|
|
115
|
+
if (document.getElementById(STYLE_ID) === null) {
|
|
116
|
+
const style = document.createElement('style');
|
|
117
|
+
style.id = STYLE_ID;
|
|
118
|
+
style.textContent = PAGE_FOCUS_CSS;
|
|
119
|
+
document.head.appendChild(style);
|
|
120
|
+
}
|
|
121
|
+
return tracker;
|
|
122
|
+
}
|
package/src/input/nav-state.ts
CHANGED
|
@@ -1,191 +1,200 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One navigable list: the highlighted element, stepping and spatial moves,
|
|
3
|
-
* and activating it. The list is re-queried on every interaction because the
|
|
4
|
-
* DOM can change under it.
|
|
5
|
-
*/
|
|
6
|
-
import type { Direction } from './gamepad';
|
|
7
|
-
import { findNearestInDirection, rectOf } from './spatial';
|
|
8
|
-
import type { NavBlurEvent, NavElement, NavElementKind, NavFocusEvent, NavTarget, Rect } from './types';
|
|
9
|
-
|
|
10
|
-
/** What a navigable list draws with (the overlay). */
|
|
11
|
-
export interface NavHighlighter {
|
|
12
|
-
/** Ring the highlighted element. */
|
|
13
|
-
showHighlight(bounds: Rect): void;
|
|
14
|
-
/** Remove the ring. */
|
|
15
|
-
hideHighlight(): void;
|
|
16
|
-
/** Open the on-screen keyboard for a text field. */
|
|
17
|
-
openKeyboard(target: HTMLInputElement | HTMLTextAreaElement | null): void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Where a list reports focus changes and activations. */
|
|
21
|
-
export interface NavListeners {
|
|
22
|
-
/** An element gained the highlight. */
|
|
23
|
-
onFocus(event: NavFocusEvent): void;
|
|
24
|
-
/** An element lost the highlight. */
|
|
25
|
-
onBlur(event: NavBlurEvent): void;
|
|
26
|
-
/** The player activated an element. */
|
|
27
|
-
onAct(event: NavFocusEvent): void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'password', 'number']);
|
|
31
|
-
|
|
32
|
-
/** What kind of element a DOM node is, for `act`. */
|
|
33
|
-
function kindOf(element: HTMLElement): NavElementKind {
|
|
34
|
-
if (element instanceof HTMLInputElement) {
|
|
35
|
-
if (element.type === 'range') {
|
|
36
|
-
return 'range';
|
|
37
|
-
}
|
|
38
|
-
if (TEXT_INPUT_TYPES.has(element.type)) {
|
|
39
|
-
return 'text';
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return element instanceof HTMLTextAreaElement ? 'text' : 'button';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** A navigable element's bounds. */
|
|
46
|
-
function boundsOf(element: NavElement): Rect {
|
|
47
|
-
if (element.getBounds !== undefined) {
|
|
48
|
-
return element.getBounds();
|
|
49
|
-
}
|
|
50
|
-
return element.el === undefined ? { x: 0, y: 0, width: 0, height: 0 } : rectOf(element.el);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
function
|
|
55
|
-
return element.el
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
this.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
current.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* One navigable list: the highlighted element, stepping and spatial moves,
|
|
3
|
+
* and activating it. The list is re-queried on every interaction because the
|
|
4
|
+
* DOM can change under it.
|
|
5
|
+
*/
|
|
6
|
+
import type { Direction } from './gamepad';
|
|
7
|
+
import { findNearestInDirection, rectOf } from './spatial';
|
|
8
|
+
import type { NavBlurEvent, NavElement, NavElementKind, NavFocusEvent, NavTarget, Rect } from './types';
|
|
9
|
+
|
|
10
|
+
/** What a navigable list draws with (the overlay). */
|
|
11
|
+
export interface NavHighlighter {
|
|
12
|
+
/** Ring the highlighted element, following its corner radius (a CSS `border-radius`, or empty). */
|
|
13
|
+
showHighlight(bounds: Rect, radius: string): void;
|
|
14
|
+
/** Remove the ring. */
|
|
15
|
+
hideHighlight(): void;
|
|
16
|
+
/** Open the on-screen keyboard for a text field. */
|
|
17
|
+
openKeyboard(target: HTMLInputElement | HTMLTextAreaElement | null): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where a list reports focus changes and activations. */
|
|
21
|
+
export interface NavListeners {
|
|
22
|
+
/** An element gained the highlight. */
|
|
23
|
+
onFocus(event: NavFocusEvent): void;
|
|
24
|
+
/** An element lost the highlight. */
|
|
25
|
+
onBlur(event: NavBlurEvent): void;
|
|
26
|
+
/** The player activated an element. */
|
|
27
|
+
onAct(event: NavFocusEvent): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'password', 'number']);
|
|
31
|
+
|
|
32
|
+
/** What kind of element a DOM node is, for `act`. */
|
|
33
|
+
function kindOf(element: HTMLElement): NavElementKind {
|
|
34
|
+
if (element instanceof HTMLInputElement) {
|
|
35
|
+
if (element.type === 'range') {
|
|
36
|
+
return 'range';
|
|
37
|
+
}
|
|
38
|
+
if (TEXT_INPUT_TYPES.has(element.type)) {
|
|
39
|
+
return 'text';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return element instanceof HTMLTextAreaElement ? 'text' : 'button';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A navigable element's bounds. */
|
|
46
|
+
function boundsOf(element: NavElement): Rect {
|
|
47
|
+
if (element.getBounds !== undefined) {
|
|
48
|
+
return element.getBounds();
|
|
49
|
+
}
|
|
50
|
+
return element.el === undefined ? { x: 0, y: 0, width: 0, height: 0 } : rectOf(element.el);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A navigable element's CSS corner radius, so the ring can follow it (empty for a non-DOM element). */
|
|
54
|
+
function radiusOf(element: NavElement): string {
|
|
55
|
+
return element.el === undefined ? '' : getComputedStyle(element.el).borderRadius;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The text field a navigable element edits, if it is one. */
|
|
59
|
+
function textFieldOf(element: NavElement): HTMLInputElement | HTMLTextAreaElement | null {
|
|
60
|
+
return element.el instanceof HTMLInputElement || element.el instanceof HTMLTextAreaElement ? element.el : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The highlight state of one navigable list. */
|
|
64
|
+
export class NavState {
|
|
65
|
+
private elements: NavElement[] = [];
|
|
66
|
+
private index = -1;
|
|
67
|
+
private readonly queryElements: () => NavTarget[];
|
|
68
|
+
private readonly shouldWrap: boolean;
|
|
69
|
+
|
|
70
|
+
constructor(queryElements: () => NavTarget[], shouldWrap = true) {
|
|
71
|
+
this.queryElements = queryElements;
|
|
72
|
+
this.shouldWrap = shouldWrap;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Re-read the list, highlighting the first element if nothing is. */
|
|
76
|
+
requery(highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
77
|
+
this.elements = this.resolve();
|
|
78
|
+
if (this.index >= this.elements.length) {
|
|
79
|
+
this.index = this.elements.length - 1;
|
|
80
|
+
}
|
|
81
|
+
if (this.index < 0 && this.elements.length > 0) {
|
|
82
|
+
this.index = 0;
|
|
83
|
+
this.focusCurrent(listeners);
|
|
84
|
+
}
|
|
85
|
+
this.position(highlighter);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Drop the highlight. */
|
|
89
|
+
blur(highlighter: NavHighlighter | null): void {
|
|
90
|
+
this.elements[this.index]?.onBlur?.();
|
|
91
|
+
this.index = -1;
|
|
92
|
+
highlighter?.hideHighlight();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Step to the next (+1) or previous (-1) element. */
|
|
96
|
+
step(delta: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
97
|
+
this.elements = this.resolve();
|
|
98
|
+
const count = this.elements.length;
|
|
99
|
+
if (count === 0) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.blurCurrent(listeners);
|
|
103
|
+
let next = this.index + delta;
|
|
104
|
+
if (next < 0) {
|
|
105
|
+
next = this.shouldWrap ? count - 1 : 0;
|
|
106
|
+
}
|
|
107
|
+
if (next >= count) {
|
|
108
|
+
next = this.shouldWrap ? 0 : count - 1;
|
|
109
|
+
}
|
|
110
|
+
this.moveTo(next, highlighter, listeners);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Move to the nearest element in `direction`; up and down spill over to the previous and next column. */
|
|
114
|
+
moveInDirection(direction: Direction, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
115
|
+
this.elements = this.resolve();
|
|
116
|
+
if (this.elements.length === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (this.index < 0) {
|
|
120
|
+
this.moveTo(0, highlighter, listeners);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const rects = this.elements.map(boundsOf);
|
|
124
|
+
let target = findNearestInDirection(rects, this.index, direction);
|
|
125
|
+
// Nothing directly that way: up falls through to the left, down to the right, so columns never dead-end.
|
|
126
|
+
const spillover = { up: 'left', down: 'right', left: null, right: null } as const;
|
|
127
|
+
const fallback = spillover[direction];
|
|
128
|
+
if (target < 0 && fallback !== null) {
|
|
129
|
+
target = findNearestInDirection(rects, this.index, fallback);
|
|
130
|
+
}
|
|
131
|
+
if (target >= 0) {
|
|
132
|
+
this.blurCurrent(listeners);
|
|
133
|
+
this.moveTo(target, highlighter, listeners);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Activate the highlighted element: open the keyboard for text, click anything else. */
|
|
138
|
+
act(highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
139
|
+
this.elements = this.resolve();
|
|
140
|
+
const current = this.elements[this.index];
|
|
141
|
+
if (current === undefined) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (current.kind === 'text') {
|
|
145
|
+
highlighter?.openKeyboard(textFieldOf(current));
|
|
146
|
+
} else {
|
|
147
|
+
current.el?.click();
|
|
148
|
+
}
|
|
149
|
+
current.onAct?.();
|
|
150
|
+
listeners.onAct({ id: current.id, el: current.el, kind: current.kind, index: this.index });
|
|
151
|
+
// Acting can change the elements.
|
|
152
|
+
this.elements = this.resolve();
|
|
153
|
+
this.position(highlighter);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Move the ring to the highlighted element (or hide it), following keyboard focus (Tab) within the list. */
|
|
157
|
+
position(highlighter: NavHighlighter | null): void {
|
|
158
|
+
if (highlighter === null) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const focused = this.elements.findIndex((element) => element.el !== undefined && element.el === document.activeElement);
|
|
162
|
+
if (focused >= 0) {
|
|
163
|
+
this.index = focused;
|
|
164
|
+
}
|
|
165
|
+
const current = this.elements[this.index];
|
|
166
|
+
if (current === undefined) {
|
|
167
|
+
highlighter.hideHighlight();
|
|
168
|
+
} else {
|
|
169
|
+
highlighter.showHighlight(boundsOf(current), radiusOf(current));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private resolve(): NavElement[] {
|
|
174
|
+
return this.queryElements().map((target) => (target instanceof HTMLElement ? { el: target, kind: kindOf(target) } : target));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private moveTo(index: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
178
|
+
this.index = index;
|
|
179
|
+
this.focusCurrent(listeners);
|
|
180
|
+
this.position(highlighter);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private focusCurrent(listeners: NavListeners): void {
|
|
184
|
+
const current = this.elements[this.index];
|
|
185
|
+
if (current === undefined) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
current.el?.focus({ preventScroll: false });
|
|
189
|
+
current.onFocus?.();
|
|
190
|
+
listeners.onFocus({ id: current.id, el: current.el, kind: current.kind, index: this.index });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private blurCurrent(listeners: NavListeners): void {
|
|
194
|
+
const current = this.elements[this.index];
|
|
195
|
+
if (current?.onBlur !== undefined) {
|
|
196
|
+
current.onBlur();
|
|
197
|
+
listeners.onBlur({ id: current.id, index: this.index });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|