@worldmesh/runtime 0.2.1 → 0.2.3

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.
Files changed (115) hide show
  1. package/README.md +48 -8
  2. package/dist/audio/footsteps.d.ts +37 -0
  3. package/dist/audio/footsteps.d.ts.map +1 -0
  4. package/dist/audio/footsteps.js +178 -0
  5. package/dist/audio/footsteps.js.map +1 -0
  6. package/dist/avatar/descriptor.d.ts +15 -0
  7. package/dist/avatar/descriptor.d.ts.map +1 -1
  8. package/dist/avatar/descriptor.js +19 -0
  9. package/dist/avatar/descriptor.js.map +1 -1
  10. package/dist/avatar/loader.d.ts +12 -1
  11. package/dist/avatar/loader.d.ts.map +1 -1
  12. package/dist/avatar/loader.js +16 -1
  13. package/dist/avatar/loader.js.map +1 -1
  14. package/dist/avatar/share.d.ts +30 -0
  15. package/dist/avatar/share.d.ts.map +1 -0
  16. package/dist/avatar/share.js +203 -0
  17. package/dist/avatar/share.js.map +1 -0
  18. package/dist/camera/cameraRig.d.ts +4 -0
  19. package/dist/camera/cameraRig.d.ts.map +1 -1
  20. package/dist/camera/cameraRig.js +10 -1
  21. package/dist/camera/cameraRig.js.map +1 -1
  22. package/dist/camera/viewHandoff.d.ts +20 -0
  23. package/dist/camera/viewHandoff.d.ts.map +1 -0
  24. package/dist/camera/viewHandoff.js +64 -0
  25. package/dist/camera/viewHandoff.js.map +1 -0
  26. package/dist/controls/input.d.ts +29 -0
  27. package/dist/controls/input.d.ts.map +1 -1
  28. package/dist/controls/input.js +124 -1
  29. package/dist/controls/input.js.map +1 -1
  30. package/dist/controls/touch.d.ts +10 -0
  31. package/dist/controls/touch.d.ts.map +1 -1
  32. package/dist/controls/touch.js +57 -1
  33. package/dist/controls/touch.js.map +1 -1
  34. package/dist/core/worldmesh.d.ts +9 -0
  35. package/dist/core/worldmesh.d.ts.map +1 -1
  36. package/dist/core/worldmesh.js +498 -26
  37. package/dist/core/worldmesh.js.map +1 -1
  38. package/dist/hosted.d.ts +3 -0
  39. package/dist/hosted.d.ts.map +1 -0
  40. package/dist/hosted.js +5 -0
  41. package/dist/hosted.js.map +1 -0
  42. package/dist/index.d.ts +16 -9
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +12 -7
  45. package/dist/index.js.map +1 -1
  46. package/dist/load.d.ts +23 -0
  47. package/dist/load.d.ts.map +1 -0
  48. package/dist/load.js +83 -0
  49. package/dist/load.js.map +1 -0
  50. package/dist/net/follow.d.ts +7 -0
  51. package/dist/net/follow.d.ts.map +1 -0
  52. package/dist/net/follow.js +10 -0
  53. package/dist/net/follow.js.map +1 -0
  54. package/dist/net/presence.d.ts +125 -3
  55. package/dist/net/presence.d.ts.map +1 -1
  56. package/dist/net/presence.js +471 -24
  57. package/dist/net/presence.js.map +1 -1
  58. package/dist/net/roomFull.d.ts +44 -0
  59. package/dist/net/roomFull.d.ts.map +1 -0
  60. package/dist/net/roomFull.js +147 -0
  61. package/dist/net/roomFull.js.map +1 -0
  62. package/dist/player/avatar.d.ts +17 -4
  63. package/dist/player/avatar.d.ts.map +1 -1
  64. package/dist/player/avatar.js +85 -18
  65. package/dist/player/avatar.js.map +1 -1
  66. package/dist/player/player.d.ts +6 -2
  67. package/dist/player/player.d.ts.map +1 -1
  68. package/dist/player/player.js +13 -3
  69. package/dist/player/player.js.map +1 -1
  70. package/dist/player/stroke.d.ts +11 -0
  71. package/dist/player/stroke.d.ts.map +1 -0
  72. package/dist/player/stroke.js +96 -0
  73. package/dist/player/stroke.js.map +1 -0
  74. package/dist/portals/portals.d.ts +1 -1
  75. package/dist/portals/portals.d.ts.map +1 -1
  76. package/dist/portals/portals.js +6 -2
  77. package/dist/portals/portals.js.map +1 -1
  78. package/dist/types.d.ts +53 -9
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/ui/overlay.d.ts +18 -0
  81. package/dist/ui/overlay.d.ts.map +1 -1
  82. package/dist/ui/overlay.js +166 -1
  83. package/dist/ui/overlay.js.map +1 -1
  84. package/dist/version.d.ts +4 -0
  85. package/dist/version.d.ts.map +1 -0
  86. package/dist/version.js +4 -0
  87. package/dist/version.js.map +1 -0
  88. package/dist/xr/session.d.ts +13 -0
  89. package/dist/xr/session.d.ts.map +1 -0
  90. package/dist/xr/session.js +33 -0
  91. package/dist/xr/session.js.map +1 -0
  92. package/package.json +1 -1
  93. package/src/audio/footsteps.ts +185 -0
  94. package/src/avatar/descriptor.ts +28 -0
  95. package/src/avatar/loader.ts +19 -2
  96. package/src/avatar/share.ts +196 -0
  97. package/src/camera/cameraRig.ts +10 -1
  98. package/src/camera/viewHandoff.ts +67 -0
  99. package/src/controls/input.ts +119 -1
  100. package/src/controls/touch.ts +59 -1
  101. package/src/core/worldmesh.ts +495 -25
  102. package/src/hosted.ts +4 -0
  103. package/src/index.ts +16 -9
  104. package/src/load.ts +103 -0
  105. package/src/net/follow.ts +9 -0
  106. package/src/net/presence.ts +547 -27
  107. package/src/net/roomFull.ts +160 -0
  108. package/src/player/avatar.ts +87 -18
  109. package/src/player/player.ts +13 -4
  110. package/src/player/stroke.ts +93 -0
  111. package/src/portals/portals.ts +5 -2
  112. package/src/types.ts +52 -9
  113. package/src/ui/overlay.ts +171 -1
  114. package/src/version.ts +3 -0
  115. package/src/xr/session.ts +33 -0
@@ -0,0 +1,196 @@
1
+ import { parseAvatarShare, type AvatarDescriptor, type SignedAvatarShare } from './descriptor.js';
2
+
3
+ /**
4
+ * Which avatar other players may see, keeping its link fresh, and checking the
5
+ * links other players send.
6
+ *
7
+ * Other players download a model straight from the platform, so a link must
8
+ * never be one a player made up: a server of their own would learn the IP
9
+ * address of everyone in the room. So only links the WorldMesh hub signed
10
+ * (Ed25519) when it resolved the avatar are sent, and a link from someone
11
+ * else is downloaded only if its signature checks out against the hub's
12
+ * public key. The hub signs Sketchfab links and at3d links on Bluesky's hosts;
13
+ * never VRoid Hub (its license needs "Multiplay" approval for other players)
14
+ * or a self-hosted AT Protocol server.
15
+ *
16
+ * Links and signatures expire (Sketchfab's links within minutes), so the owner
17
+ * asks for a fresh one before the old runs out; someone joining later still
18
+ * gets a working link. Players who already loaded the model keep it.
19
+ */
20
+
21
+ /** Never shown to other players, even if a hub signed it. */
22
+ const PRIVATE_PROVIDERS: readonly string[] = ['vroid'];
23
+ /** Ask for a new link this long before the old one expires. */
24
+ const REFRESH_EARLY_MS = 45_000;
25
+ const MIN_REFRESH_MS = 30_000;
26
+ const SHARE_CONTEXT = 'worldmesh-avatar-share-v1';
27
+
28
+ /** The hub-signed link other players may be shown, or null when there is none. */
29
+ export function shareableAvatar(descriptor: AvatarDescriptor | null): SignedAvatarShare | null {
30
+ if (!descriptor || PRIVATE_PROVIDERS.includes(descriptor.provider)) return null;
31
+ return parseAvatarShare(descriptor.share);
32
+ }
33
+
34
+ /** Same avatar, possibly a different link. */
35
+ function sameAvatar(a: AvatarDescriptor, b: AvatarDescriptor): boolean {
36
+ if (a.avatarId || b.avatarId) return a.provider === b.provider && a.avatarId === b.avatarId;
37
+ return a.modelUrl === b.modelUrl;
38
+ }
39
+
40
+ /**
41
+ * The avatar this visitor shows other players. Set it whenever the local
42
+ * avatar changes; with `resolve` (asks the hub again), it keeps the signed
43
+ * link current, and fetches one if the descriptor came without.
44
+ */
45
+ export class SharedAvatarLink {
46
+ private descriptor: AvatarDescriptor | null = null;
47
+ private shared: SignedAvatarShare | null = null;
48
+ private timer = 0;
49
+ private turn = 0;
50
+
51
+ constructor(private resolve: (() => Promise<AvatarDescriptor | null>) | null) {}
52
+
53
+ set(descriptor: AvatarDescriptor | null): void {
54
+ this.turn += 1;
55
+ window.clearTimeout(this.timer);
56
+ this.descriptor = descriptor;
57
+ this.shared = shareableAvatar(descriptor);
58
+ // Handed over without a signed link (e.g. by an older hub): ask for one now.
59
+ if (descriptor && !this.shared && !PRIVATE_PROVIDERS.includes(descriptor.provider) && descriptor.share === undefined && this.resolve) {
60
+ const turn = this.turn;
61
+ this.timer = window.setTimeout(() => void this.refresh(turn), 0);
62
+ return;
63
+ }
64
+ this.schedule();
65
+ }
66
+
67
+ get(): SignedAvatarShare | null {
68
+ return this.shared;
69
+ }
70
+
71
+ dispose(): void {
72
+ this.turn += 1;
73
+ window.clearTimeout(this.timer);
74
+ this.resolve = null;
75
+ }
76
+
77
+ private schedule(): void {
78
+ const descriptor = this.descriptor;
79
+ const shared = this.shared;
80
+ if (!this.resolve || !shared || !descriptor) return;
81
+ const linkExpires = descriptor.expiresAt ? Date.parse(descriptor.expiresAt) : Infinity;
82
+ const expires = Math.min(shared.x * 1000, Number.isFinite(linkExpires) ? linkExpires : Infinity);
83
+ const delay = Math.max(MIN_REFRESH_MS, expires - Date.now() - REFRESH_EARLY_MS);
84
+ const turn = this.turn;
85
+ this.timer = window.setTimeout(() => void this.refresh(turn), delay);
86
+ }
87
+
88
+ private async refresh(turn: number): Promise<void> {
89
+ if (turn !== this.turn || !this.resolve) return;
90
+ // Nobody new sees a hidden tab's link expire; ask again once it is back.
91
+ if (document.hidden) {
92
+ this.timer = window.setTimeout(() => void this.refresh(turn), MIN_REFRESH_MS);
93
+ return;
94
+ }
95
+ let fresh: AvatarDescriptor | null = null;
96
+ try {
97
+ fresh = await this.resolve();
98
+ } catch {
99
+ // The hub could not be reached just now. Ask again shortly, or the link
100
+ // runs out for anyone joining later.
101
+ if (turn === this.turn) this.timer = window.setTimeout(() => void this.refresh(turn), MIN_REFRESH_MS);
102
+ return;
103
+ }
104
+ if (turn !== this.turn || !this.descriptor) return;
105
+ // A different avatar (picked on the hub since) is not swapped in here; the
106
+ // body this visitor sees and the one others see stay the same.
107
+ if (!fresh || !sameAvatar(fresh, this.descriptor)) return;
108
+ this.descriptor = fresh;
109
+ this.shared = shareableAvatar(fresh);
110
+ this.schedule();
111
+ }
112
+ }
113
+
114
+ /** The exact bytes the hub signs (workers/auth/src/avatars/share.ts). */
115
+ function sharePayload(share: SignedAvatarShare): Uint8Array<ArrayBuffer> {
116
+ return new TextEncoder().encode(`${SHARE_CONTEXT}\n${share.u}\n${share.f}\n${share.i}\n${share.x}`);
117
+ }
118
+
119
+ const verifiers = new Map<string, (share: SignedAvatarShare) => Promise<boolean>>();
120
+ /** After the hub's key failed to load, wait this long before asking for it again. */
121
+ const KEY_RETRY_MS = 30_000;
122
+
123
+ /**
124
+ * Checks links other players send against the public key of the hub at
125
+ * `hubUrl` (fetched once from its resolve endpoint). Says no when the hub has
126
+ * no key or the browser cannot check Ed25519, so nothing unchecked is ever
127
+ * downloaded. Throws when the key could not be fetched just now (offline for a
128
+ * moment, the hub mid-deploy): the link may well be fine, so ask again later.
129
+ */
130
+ export function createShareVerifier(hubUrl: string): (share: SignedAvatarShare) => Promise<boolean> {
131
+ let endpoint: string;
132
+ try {
133
+ endpoint = new URL('/api/account/avatar/resolve', hubUrl).toString();
134
+ } catch {
135
+ return async () => false;
136
+ }
137
+ const existing = verifiers.get(endpoint);
138
+ if (existing) return existing;
139
+
140
+ // Resolves to null when links can never be checked here; rejects when the key could not be fetched.
141
+ let key: Promise<CryptoKey | null> | null = null;
142
+ let failedAt = 0;
143
+ const loadKey = (): Promise<CryptoKey | null> => {
144
+ // After a failure, fetch again a little later rather than for every link.
145
+ if (key && !(failedAt && Date.now() - failedAt > KEY_RETRY_MS)) return key;
146
+ failedAt = 0;
147
+ key = (async () => {
148
+ let x: string | null;
149
+ try {
150
+ const response = await fetch(endpoint, { method: 'GET', mode: 'cors', credentials: 'omit', referrerPolicy: 'no-referrer' });
151
+ if (!response.ok) throw new Error(`Key request failed (${response.status})`);
152
+ const body = (await response.json()) as { key?: { kty?: unknown; crv?: unknown; x?: unknown } | null };
153
+ const jwk = body.key;
154
+ if (jwk === null) x = null;
155
+ else if (!jwk || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string') throw new Error('Unexpected share key');
156
+ else x = jwk.x;
157
+ } catch (error) {
158
+ failedAt = Date.now();
159
+ console.warn('[worldmesh] Cannot load the hub’s avatar key yet; showing default bodies for now.', error);
160
+ throw error;
161
+ }
162
+ if (!x) {
163
+ console.warn('[worldmesh] This hub does not vouch for avatar links; showing default bodies.');
164
+ return null;
165
+ }
166
+ try {
167
+ return await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x }, { name: 'Ed25519' }, false, ['verify']);
168
+ } catch (error) {
169
+ console.warn('[worldmesh] This browser cannot check other players’ avatar links; showing default bodies.', error);
170
+ return null;
171
+ }
172
+ })();
173
+ return key;
174
+ };
175
+
176
+ const verify = async (share: SignedAvatarShare): Promise<boolean> => {
177
+ // Throws while the key cannot be fetched: the caller tries again later.
178
+ const publicKey = await loadKey();
179
+ if (!publicKey) return false;
180
+ try {
181
+ return await crypto.subtle.verify({ name: 'Ed25519' }, publicKey, fromBase64url(share.s), sharePayload(share));
182
+ } catch {
183
+ return false;
184
+ }
185
+ };
186
+ verifiers.set(endpoint, verify);
187
+ return verify;
188
+ }
189
+
190
+ function fromBase64url(text: string): Uint8Array<ArrayBuffer> {
191
+ const base64 = text.replace(/-/g, '+').replace(/_/g, '/');
192
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
193
+ const bytes = new Uint8Array(binary.length);
194
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
195
+ return bytes;
196
+ }
@@ -31,6 +31,8 @@ export class CameraRig {
31
31
  private offset = new Vector3();
32
32
  /** Smoothed boom length so the camera does not pop when it clears a wall. */
33
33
  private currentBoom: number;
34
+ /** After snapBoom: ease back out slowly, a pull-back rather than a jump. */
35
+ private gentle = false;
34
36
 
35
37
  constructor(options: CameraRigOptions) {
36
38
  this.camera = options.camera;
@@ -61,6 +63,12 @@ export class CameraRig {
61
63
  if (mode === 'third') this.currentBoom = this.distance;
62
64
  }
63
65
 
66
+ /** Put the third-person camera this far back right now; it eases back to `distance` from there. */
67
+ snapBoom(length: number): void {
68
+ this.currentBoom = clamp(length, 0.4, this.maxDistance);
69
+ this.gentle = true;
70
+ }
71
+
64
72
  toggleMode(): ViewMode {
65
73
  this.setMode(this.mode === 'first' ? 'third' : 'first');
66
74
  return this.mode;
@@ -83,8 +91,9 @@ export class CameraRig {
83
91
  const wanted = this.collision.castDistance(this.target, this.offset, this.distance + 0.3) - 0.3;
84
92
  const boom = clamp(wanted, 0.4, this.distance);
85
93
  // Snap in fast, ease out slowly: popping into geometry is worse than lag.
86
- const rate = boom < this.currentBoom ? 1 : 1 - Math.exp(-8 * dt);
94
+ const rate = boom < this.currentBoom ? 1 : 1 - Math.exp(-(this.gentle ? 1.6 : 8) * dt);
87
95
  this.currentBoom += (boom - this.currentBoom) * rate;
96
+ if (this.gentle && (boom < this.currentBoom || this.currentBoom > boom - 0.05)) this.gentle = false;
88
97
  this.camera.position.copy(this.target).addScaledVector(this.offset, this.currentBoom);
89
98
  }
90
99
 
@@ -0,0 +1,67 @@
1
+ import type { ViewMode } from '../types.js';
2
+
3
+ /**
4
+ * Remembering the visitor's camera view (first or third person) between worlds.
5
+ *
6
+ * Worlds live on different origins, so storage alone cannot carry it: travel
7
+ * URLs add it to the fragment (`#wm-view=third`), and each world also keeps it
8
+ * in localStorage so it holds across reloads and later visits.
9
+ *
10
+ * Only a view the visitor picked (or was handed) is carried. Until then every
11
+ * world opens in its own default.
12
+ */
13
+
14
+ export const VIEW_PARAM = 'wm-view';
15
+ export const VIEW_STORAGE_KEY = 'worldmesh.view';
16
+
17
+ let current: ViewMode | null = null;
18
+
19
+ function isViewMode(value: unknown): value is ViewMode {
20
+ return value === 'first' || value === 'third';
21
+ }
22
+
23
+ /** Moves a view from this page's URL fragment into storage, and returns the remembered one. */
24
+ export function takeViewHandoff(): ViewMode | null {
25
+ const hash = window.location.hash.slice(1);
26
+ if (hash) {
27
+ const params = new URLSearchParams(hash);
28
+ const view = params.get(VIEW_PARAM);
29
+ if (view !== null) {
30
+ params.delete(VIEW_PARAM);
31
+ const rest = params.toString();
32
+ history.replaceState(history.state, '', `${window.location.pathname}${window.location.search}${rest ? `#${rest}` : ''}`);
33
+ if (isViewMode(view)) rememberView(view);
34
+ }
35
+ }
36
+ if (!current) {
37
+ try {
38
+ const stored = localStorage.getItem(VIEW_STORAGE_KEY);
39
+ if (isViewMode(stored)) current = stored;
40
+ } catch {
41
+ // Storage blocked: the view still follows through travel URLs.
42
+ }
43
+ }
44
+ return current;
45
+ }
46
+
47
+ export function getRememberedView(): ViewMode | null {
48
+ return current;
49
+ }
50
+
51
+ export function rememberView(mode: ViewMode): void {
52
+ current = mode;
53
+ try {
54
+ localStorage.setItem(VIEW_STORAGE_KEY, mode);
55
+ } catch {
56
+ // Storage blocked: the view still follows through travel URLs.
57
+ }
58
+ }
59
+
60
+ /** Adds the remembered view to a destination URL's fragment, next to any other WorldMesh handoff. */
61
+ export function withView(url: URL): URL {
62
+ if (!current || !/^https?:$/.test(url.protocol)) return url;
63
+ const params = new URLSearchParams(url.hash.slice(1));
64
+ params.set(VIEW_PARAM, current);
65
+ url.hash = params.toString();
66
+ return url;
67
+ }
@@ -42,6 +42,18 @@ export class Input {
42
42
  private onEscape?: () => boolean | void;
43
43
  private touch?: TouchControls;
44
44
  private disposed = false;
45
+ /** Letting go of the cursor to leave the page, not to pause. */
46
+ private releasing = false;
47
+ private xrSession: XRSession | null = null;
48
+ private xrMoveX = 0;
49
+ private xrMoveZ = 0;
50
+ private xrTurn = 0;
51
+ private xrSprint = false;
52
+ private xrJumpHeld = false;
53
+ private xrInteractHeld = false;
54
+ /** Movement carried over from outside (a hub's joystick, still held as the visitor walked in). */
55
+ private carriedX = 0;
56
+ private carriedZ = 0;
45
57
 
46
58
  constructor(options: InputOptions) {
47
59
  this.element = options.element;
@@ -81,7 +93,7 @@ export class Input {
81
93
  }
82
94
 
83
95
  requestPointerLock(touchTriggered = false): void {
84
- if (this.disposed || this.locked) return;
96
+ if (this.disposed || this.locked || this.xrSession) return;
85
97
 
86
98
  // Mobile / touch devices do not support pointer lock. Enter directly.
87
99
  if (touchTriggered || isTouchDevice()) {
@@ -135,12 +147,91 @@ export class Input {
135
147
  }
136
148
  }
137
149
 
150
+ /**
151
+ * Free the cursor without bringing up the pause screen, for leaving the page.
152
+ * With moveBeforeLock the visitor stays walking, so coming back with the
153
+ * browser's back button finds them in the world instead of on the menu.
154
+ */
155
+ release(): void {
156
+ if (!this.moveBeforeLock) {
157
+ this.exitPointerLock();
158
+ return;
159
+ }
160
+ this.pressed.clear();
161
+ this.justPressed.clear();
162
+ this.touch?.reset();
163
+ if (document.pointerLockElement === this.element) {
164
+ this.releasing = true;
165
+ document.exitPointerLock();
166
+ }
167
+ }
168
+
138
169
  triggerAction(action: InputAction): void {
139
170
  this.justPressed.add(action);
140
171
  }
141
172
 
173
+ /** WebXR controllers join the same move / jump / interact actions as the keyboard. */
174
+ setXrSession(session: XRSession | null): void {
175
+ this.xrSession = session;
176
+ this.xrMoveX = 0;
177
+ this.xrMoveZ = 0;
178
+ this.xrTurn = 0;
179
+ this.xrSprint = false;
180
+ this.xrJumpHeld = false;
181
+ this.xrInteractHeld = false;
182
+ if (session) this.touch?.setVisible(false);
183
+ else this.touch?.setVisible(this.locked && isTouchDevice());
184
+ }
185
+
186
+ /**
187
+ * Read XR gamepads for this frame. Left stick walks, right stick turns,
188
+ * trigger or A/X jumps, squeeze interacts.
189
+ */
190
+ pollXr(dt: number): void {
191
+ this.xrMoveX = 0;
192
+ this.xrMoveZ = 0;
193
+ this.xrTurn = 0;
194
+ this.xrSprint = false;
195
+ let jump = false;
196
+ let interact = false;
197
+ if (this.xrSession) {
198
+ for (const source of this.xrSession.inputSources) {
199
+ const pad = source.gamepad;
200
+ if (!pad) continue;
201
+ const stick = readThumbstick(pad);
202
+ if (source.handedness === 'right') {
203
+ this.xrTurn += stick.x * XR_TURN_SPEED * dt;
204
+ } else {
205
+ this.xrMoveX += stick.x;
206
+ this.xrMoveZ += stick.y;
207
+ }
208
+ if (pad.buttons[0]?.pressed || pad.buttons[4]?.pressed) jump = true;
209
+ if (pad.buttons[1]?.pressed) interact = true;
210
+ if (pad.buttons[3]?.pressed) this.xrSprint = true;
211
+ }
212
+ const length = Math.hypot(this.xrMoveX, this.xrMoveZ);
213
+ if (length > 1) {
214
+ this.xrMoveX /= length;
215
+ this.xrMoveZ /= length;
216
+ }
217
+ }
218
+ if (jump && !this.xrJumpHeld) this.justPressed.add('jump');
219
+ if (interact && !this.xrInteractHeld) this.justPressed.add('interact');
220
+ this.xrJumpHeld = jump;
221
+ this.xrInteractHeld = interact;
222
+ }
223
+
224
+ /** Radians to apply to the XR play-space this frame (right stick). */
225
+ readXrTurn(): number {
226
+ const turn = this.xrTurn;
227
+ this.xrTurn = 0;
228
+ return turn;
229
+ }
230
+
142
231
  isDown(action: InputAction): boolean {
143
232
  if (this.touch?.isDown(action)) return true;
233
+ if (action === 'sprint' && this.xrSprint) return true;
234
+ if (action === 'jump' && this.xrJumpHeld) return true;
144
235
  for (const code of this.keymap[action]) if (this.pressed.has(code)) return true;
145
236
  return false;
146
237
  }
@@ -160,6 +251,12 @@ export class Input {
160
251
  return digit;
161
252
  }
162
253
 
254
+ /** Keep moving this way (same axes as getMoveAxis) until told otherwise; 0, 0 stops. */
255
+ carry(x: number, z: number): void {
256
+ this.carriedX = Number.isFinite(x) ? Math.max(-1, Math.min(1, x)) : 0;
257
+ this.carriedZ = Number.isFinite(z) ? Math.max(-1, Math.min(1, z)) : 0;
258
+ }
259
+
163
260
  /** Movement intent on the local XZ plane, already normalized. */
164
261
  getMoveAxis(): { x: number; z: number } {
165
262
  let x = (this.isDown('right') ? 1 : 0) - (this.isDown('left') ? 1 : 0);
@@ -170,6 +267,10 @@ export class Input {
170
267
  x += touchAxis.x;
171
268
  z += touchAxis.z;
172
269
  }
270
+ x += this.xrMoveX;
271
+ z += this.xrMoveZ;
272
+ x += this.carriedX;
273
+ z += this.carriedZ;
173
274
 
174
275
  const length = Math.hypot(x, z);
175
276
  if (length === 0) return { x: 0, z: 0 };
@@ -251,6 +352,7 @@ export class Input {
251
352
  private handleBlur = (): void => {
252
353
  this.pressed.clear();
253
354
  this.justPressed.clear();
355
+ this.touch?.reset();
254
356
  };
255
357
 
256
358
  /**
@@ -288,6 +390,12 @@ export class Input {
288
390
 
289
391
  private handlePointerLockChange = (): void => {
290
392
  const isLocked = document.pointerLockElement === this.element;
393
+ if (!isLocked && this.releasing) {
394
+ // Quiet: the overlay keeps its walking state.
395
+ this.releasing = false;
396
+ this.locked = false;
397
+ return;
398
+ }
291
399
  if (isLocked) {
292
400
  this.setLocked(true);
293
401
  } else if (!isTouchDevice()) {
@@ -304,6 +412,16 @@ export class Input {
304
412
  };
305
413
  }
306
414
 
415
+ const XR_TURN_SPEED = 2.4;
416
+ const XR_DEADZONE = 0.18;
417
+
418
+ function readThumbstick(pad: Gamepad): { x: number; y: number } {
419
+ const x = pad.axes.length >= 4 ? pad.axes[2] : (pad.axes[0] ?? 0);
420
+ const y = pad.axes.length >= 4 ? pad.axes[3] : (pad.axes[1] ?? 0);
421
+ if (Math.hypot(x, y) < XR_DEADZONE) return { x: 0, y: 0 };
422
+ return { x, y };
423
+ }
424
+
307
425
  function isTypingTarget(target: EventTarget | null): boolean {
308
426
  if (!(target instanceof HTMLElement)) return false;
309
427
  const tag = target.tagName;
@@ -102,6 +102,19 @@ const TOUCH_CSS = `
102
102
  font-size: 14px;
103
103
  opacity: 0.85;
104
104
  }
105
+ .wm-btn-sprint {
106
+ width: 48px;
107
+ height: 48px;
108
+ }
109
+ .wm-btn-sprint svg {
110
+ display: block;
111
+ pointer-events: none;
112
+ }
113
+ /* Run is a toggle, not a hold: both thumbs are busy steering and looking. */
114
+ .wm-btn-sprint.wm-on {
115
+ background: rgba(255, 255, 255, 0.85);
116
+ color: #080b10;
117
+ }
105
118
  .wm-touch-menu-btn {
106
119
  position: absolute;
107
120
  top: 14px;
@@ -156,6 +169,8 @@ export class TouchControls {
156
169
  private lookDelta = { dx: 0, dy: 0 };
157
170
 
158
171
  private actionsDown = new Set<InputAction>();
172
+ private sprintBtn: HTMLButtonElement;
173
+ private sprinting = false;
159
174
  private justPressed = new Set<InputAction>();
160
175
 
161
176
  private disposed = false;
@@ -186,7 +201,21 @@ export class TouchControls {
186
201
  const interactBtn = this.createActionButton('E', 'interact', 'wm-btn-interact', 'Interact');
187
202
  const jumpBtn = this.createActionButton('▲', 'jump', 'wm-btn-jump', 'Jump');
188
203
 
189
- buttons.append(viewBtn, interactBtn, jumpBtn);
204
+ this.sprintBtn = document.createElement('button');
205
+ this.sprintBtn.type = 'button';
206
+ this.sprintBtn.className = 'wm-touch-btn wm-btn-sprint';
207
+ // An SVG, not a '»' glyph: font metrics push text off the circle's centre.
208
+ this.sprintBtn.innerHTML =
209
+ '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/></svg>';
210
+ this.sprintBtn.title = 'Run';
211
+ this.sprintBtn.setAttribute('aria-pressed', 'false');
212
+ this.sprintBtn.addEventListener('pointerdown', (e) => {
213
+ e.stopPropagation();
214
+ e.preventDefault();
215
+ this.setSprinting(!this.sprinting);
216
+ });
217
+
218
+ buttons.append(viewBtn, interactBtn, this.sprintBtn, jumpBtn);
190
219
  this.root.appendChild(buttons);
191
220
 
192
221
  // Menu / Pause button (top right)
@@ -213,8 +242,24 @@ export class TouchControls {
213
242
  window.addEventListener('pointermove', this.handlePointerMove);
214
243
  window.addEventListener('pointerup', this.handlePointerUp);
215
244
  window.addEventListener('pointercancel', this.handlePointerUp);
245
+ window.addEventListener('pagehide', this.handlePageLeave);
246
+ window.addEventListener('pageshow', this.handlePageLeave);
247
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
216
248
  }
217
249
 
250
+ /**
251
+ * Walking through a door while holding the stick leaves the page mid-drag, so
252
+ * its pointerup never arrives. Back-button restores the page as it was left,
253
+ * stick still held. Let go whenever the page is left or comes back.
254
+ */
255
+ private handlePageLeave = (): void => {
256
+ this.reset();
257
+ };
258
+
259
+ private handleVisibilityChange = (): void => {
260
+ if (document.visibilityState === 'hidden') this.reset();
261
+ };
262
+
218
263
  private createActionButton(
219
264
  label: string,
220
265
  action: InputAction,
@@ -316,6 +361,8 @@ export class TouchControls {
316
361
  if (e.pointerId === this.movePointerId) {
317
362
  this.movePointerId = null;
318
363
  this.moveAxis = { x: 0, z: 0 };
364
+ // Like most mobile games, stopping ends the run.
365
+ this.setSprinting(false);
319
366
  this.restJoystick();
320
367
  }
321
368
  if (e.pointerId === this.lookPointerId) {
@@ -334,6 +381,7 @@ export class TouchControls {
334
381
  }
335
382
 
336
383
  isDown(action: InputAction): boolean {
384
+ if (action === 'sprint' && this.sprinting) return true;
337
385
  return this.actionsDown.has(action);
338
386
  }
339
387
 
@@ -362,9 +410,16 @@ export class TouchControls {
362
410
  this.lookDelta = { dx: 0, dy: 0 };
363
411
  this.actionsDown.clear();
364
412
  this.justPressed.clear();
413
+ this.setSprinting(false);
365
414
  this.restJoystick();
366
415
  }
367
416
 
417
+ private setSprinting(on: boolean): void {
418
+ this.sprinting = on;
419
+ this.sprintBtn.classList.toggle('wm-on', on);
420
+ this.sprintBtn.setAttribute('aria-pressed', String(on));
421
+ }
422
+
368
423
  private restJoystick(): void {
369
424
  this.joystickBase.classList.add('wm-idle');
370
425
  this.joystickBase.style.left = '';
@@ -378,6 +433,9 @@ export class TouchControls {
378
433
  window.removeEventListener('pointermove', this.handlePointerMove);
379
434
  window.removeEventListener('pointerup', this.handlePointerUp);
380
435
  window.removeEventListener('pointercancel', this.handlePointerUp);
436
+ window.removeEventListener('pagehide', this.handlePageLeave);
437
+ window.removeEventListener('pageshow', this.handlePageLeave);
438
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
381
439
  this.root.remove();
382
440
  }
383
441
  }