@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.
- package/README.md +48 -8
- package/dist/audio/footsteps.d.ts +37 -0
- package/dist/audio/footsteps.d.ts.map +1 -0
- package/dist/audio/footsteps.js +178 -0
- package/dist/audio/footsteps.js.map +1 -0
- package/dist/avatar/descriptor.d.ts +15 -0
- package/dist/avatar/descriptor.d.ts.map +1 -1
- package/dist/avatar/descriptor.js +19 -0
- package/dist/avatar/descriptor.js.map +1 -1
- package/dist/avatar/loader.d.ts +12 -1
- package/dist/avatar/loader.d.ts.map +1 -1
- package/dist/avatar/loader.js +16 -1
- package/dist/avatar/loader.js.map +1 -1
- package/dist/avatar/share.d.ts +30 -0
- package/dist/avatar/share.d.ts.map +1 -0
- package/dist/avatar/share.js +203 -0
- package/dist/avatar/share.js.map +1 -0
- package/dist/camera/cameraRig.d.ts +4 -0
- package/dist/camera/cameraRig.d.ts.map +1 -1
- package/dist/camera/cameraRig.js +10 -1
- package/dist/camera/cameraRig.js.map +1 -1
- package/dist/camera/viewHandoff.d.ts +20 -0
- package/dist/camera/viewHandoff.d.ts.map +1 -0
- package/dist/camera/viewHandoff.js +64 -0
- package/dist/camera/viewHandoff.js.map +1 -0
- package/dist/controls/input.d.ts +29 -0
- package/dist/controls/input.d.ts.map +1 -1
- package/dist/controls/input.js +124 -1
- package/dist/controls/input.js.map +1 -1
- package/dist/controls/touch.d.ts +10 -0
- package/dist/controls/touch.d.ts.map +1 -1
- package/dist/controls/touch.js +57 -1
- package/dist/controls/touch.js.map +1 -1
- package/dist/core/worldmesh.d.ts +9 -0
- package/dist/core/worldmesh.d.ts.map +1 -1
- package/dist/core/worldmesh.js +498 -26
- package/dist/core/worldmesh.js.map +1 -1
- package/dist/hosted.d.ts +3 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/hosted.js +5 -0
- package/dist/hosted.js.map +1 -0
- package/dist/index.d.ts +16 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -7
- package/dist/index.js.map +1 -1
- package/dist/load.d.ts +23 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +83 -0
- package/dist/load.js.map +1 -0
- package/dist/net/follow.d.ts +7 -0
- package/dist/net/follow.d.ts.map +1 -0
- package/dist/net/follow.js +10 -0
- package/dist/net/follow.js.map +1 -0
- package/dist/net/presence.d.ts +125 -3
- package/dist/net/presence.d.ts.map +1 -1
- package/dist/net/presence.js +471 -24
- package/dist/net/presence.js.map +1 -1
- package/dist/net/roomFull.d.ts +44 -0
- package/dist/net/roomFull.d.ts.map +1 -0
- package/dist/net/roomFull.js +147 -0
- package/dist/net/roomFull.js.map +1 -0
- package/dist/player/avatar.d.ts +17 -4
- package/dist/player/avatar.d.ts.map +1 -1
- package/dist/player/avatar.js +85 -18
- package/dist/player/avatar.js.map +1 -1
- package/dist/player/player.d.ts +6 -2
- package/dist/player/player.d.ts.map +1 -1
- package/dist/player/player.js +13 -3
- package/dist/player/player.js.map +1 -1
- package/dist/player/stroke.d.ts +11 -0
- package/dist/player/stroke.d.ts.map +1 -0
- package/dist/player/stroke.js +96 -0
- package/dist/player/stroke.js.map +1 -0
- package/dist/portals/portals.d.ts +1 -1
- package/dist/portals/portals.d.ts.map +1 -1
- package/dist/portals/portals.js +6 -2
- package/dist/portals/portals.js.map +1 -1
- package/dist/types.d.ts +53 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/overlay.d.ts +18 -0
- package/dist/ui/overlay.d.ts.map +1 -1
- package/dist/ui/overlay.js +166 -1
- package/dist/ui/overlay.js.map +1 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/xr/session.d.ts +13 -0
- package/dist/xr/session.d.ts.map +1 -0
- package/dist/xr/session.js +33 -0
- package/dist/xr/session.js.map +1 -0
- package/package.json +1 -1
- package/src/audio/footsteps.ts +185 -0
- package/src/avatar/descriptor.ts +28 -0
- package/src/avatar/loader.ts +19 -2
- package/src/avatar/share.ts +196 -0
- package/src/camera/cameraRig.ts +10 -1
- package/src/camera/viewHandoff.ts +67 -0
- package/src/controls/input.ts +119 -1
- package/src/controls/touch.ts +59 -1
- package/src/core/worldmesh.ts +495 -25
- package/src/hosted.ts +4 -0
- package/src/index.ts +16 -9
- package/src/load.ts +103 -0
- package/src/net/follow.ts +9 -0
- package/src/net/presence.ts +547 -27
- package/src/net/roomFull.ts +160 -0
- package/src/player/avatar.ts +87 -18
- package/src/player/player.ts +13 -4
- package/src/player/stroke.ts +93 -0
- package/src/portals/portals.ts +5 -2
- package/src/types.ts +52 -9
- package/src/ui/overlay.ts +171 -1
- package/src/version.ts +3 -0
- 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
|
+
}
|
package/src/camera/cameraRig.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/controls/input.ts
CHANGED
|
@@ -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;
|
package/src/controls/touch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|