@worldmesh/runtime 0.1.0 → 0.2.0

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 (72) hide show
  1. package/README.md +21 -2
  2. package/dist/avatar/descriptor.d.ts +27 -0
  3. package/dist/avatar/descriptor.d.ts.map +1 -0
  4. package/dist/avatar/descriptor.js +41 -0
  5. package/dist/avatar/descriptor.js.map +1 -0
  6. package/dist/avatar/handoff.d.ts +29 -0
  7. package/dist/avatar/handoff.d.ts.map +1 -0
  8. package/dist/avatar/handoff.js +92 -0
  9. package/dist/avatar/handoff.js.map +1 -0
  10. package/dist/avatar/loader.d.ts +26 -0
  11. package/dist/avatar/loader.d.ts.map +1 -0
  12. package/dist/avatar/loader.js +177 -0
  13. package/dist/avatar/loader.js.map +1 -0
  14. package/dist/controls/input.d.ts +25 -0
  15. package/dist/controls/input.d.ts.map +1 -1
  16. package/dist/controls/input.js +60 -2
  17. package/dist/controls/input.js.map +1 -1
  18. package/dist/controls/touch.d.ts.map +1 -1
  19. package/dist/controls/touch.js +5 -0
  20. package/dist/controls/touch.js.map +1 -1
  21. package/dist/core/worldmesh.d.ts.map +1 -1
  22. package/dist/core/worldmesh.js +114 -8
  23. package/dist/core/worldmesh.js.map +1 -1
  24. package/dist/index.d.ts +10 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/movement/controller.d.ts +10 -1
  29. package/dist/movement/controller.d.ts.map +1 -1
  30. package/dist/movement/controller.js +61 -4
  31. package/dist/movement/controller.js.map +1 -1
  32. package/dist/net/adapter.d.ts +3 -14
  33. package/dist/net/adapter.d.ts.map +1 -1
  34. package/dist/net/adapter.js.map +1 -1
  35. package/dist/net/presence.d.ts +68 -0
  36. package/dist/net/presence.d.ts.map +1 -0
  37. package/dist/net/presence.js +425 -0
  38. package/dist/net/presence.js.map +1 -0
  39. package/dist/player/avatar.d.ts +4 -0
  40. package/dist/player/avatar.d.ts.map +1 -1
  41. package/dist/player/avatar.js +97 -4
  42. package/dist/player/avatar.js.map +1 -1
  43. package/dist/player/player.d.ts +13 -0
  44. package/dist/player/player.d.ts.map +1 -1
  45. package/dist/player/player.js +44 -5
  46. package/dist/player/player.js.map +1 -1
  47. package/dist/portals/portals.d.ts +4 -1
  48. package/dist/portals/portals.d.ts.map +1 -1
  49. package/dist/portals/portals.js +6 -2
  50. package/dist/portals/portals.js.map +1 -1
  51. package/dist/types.d.ts +78 -4
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/ui/overlay.d.ts +7 -0
  54. package/dist/ui/overlay.d.ts.map +1 -1
  55. package/dist/ui/overlay.js +62 -7
  56. package/dist/ui/overlay.js.map +1 -1
  57. package/package.json +4 -1
  58. package/src/avatar/descriptor.ts +56 -0
  59. package/src/avatar/handoff.ts +91 -0
  60. package/src/avatar/loader.ts +184 -0
  61. package/src/controls/input.ts +61 -2
  62. package/src/controls/touch.ts +5 -0
  63. package/src/core/worldmesh.ts +110 -8
  64. package/src/index.ts +21 -1
  65. package/src/movement/controller.ts +59 -5
  66. package/src/net/adapter.ts +3 -14
  67. package/src/net/presence.ts +498 -0
  68. package/src/player/avatar.ts +109 -4
  69. package/src/player/player.ts +42 -3
  70. package/src/portals/portals.ts +6 -2
  71. package/src/types.ts +77 -4
  72. package/src/ui/overlay.ts +60 -7
@@ -1,4 +1,5 @@
1
1
  import { Group, Mesh, Object3D, type Vector3 } from 'three';
2
+ import type { LoadedAvatar } from '../avatar/loader.js';
2
3
  import type { PlayerOptions } from '../types.js';
3
4
  import {
4
5
  animateDefaultAvatar,
@@ -30,6 +31,9 @@ export class Player {
30
31
  facing = 0;
31
32
 
32
33
  private isDefaultAvatar: boolean;
34
+ /** The body the world started with, kept aside while an external avatar is shown. */
35
+ private baseBody: Object3D | null = null;
36
+ private external: LoadedAvatar | null = null;
33
37
 
34
38
  constructor(options: PlayerAvatarOptions) {
35
39
  this.height = options.height;
@@ -48,6 +52,38 @@ export class Player {
48
52
 
49
53
  const body = options.avatar ?? createDefaultAvatar(options.height);
50
54
  this.root.add(body as Object3D);
55
+ this.baseBody = body;
56
+ }
57
+
58
+ /** Whether an external avatar (e.g. from the visitor's Avatar Wallet) is shown. */
59
+ get hasExternalBody(): boolean {
60
+ return !!this.external;
61
+ }
62
+
63
+ /**
64
+ * Show an external avatar instead of the world's body. The original body is
65
+ * kept, so `clearExternalBody()` brings it back. Ignored when the world
66
+ * turned the avatar off (`avatar: false`).
67
+ */
68
+ setExternalBody(avatar: LoadedAvatar): boolean {
69
+ if (!this.root || !this.baseBody) {
70
+ avatar.dispose();
71
+ return false;
72
+ }
73
+ this.clearExternalBody();
74
+ this.baseBody.visible = false;
75
+ this.root.scale.y = 1;
76
+ this.root.add(avatar.object);
77
+ this.external = avatar;
78
+ return true;
79
+ }
80
+
81
+ clearExternalBody(): void {
82
+ if (!this.external) return;
83
+ this.external.object.removeFromParent();
84
+ this.external.dispose();
85
+ this.external = null;
86
+ if (this.baseBody) this.baseBody.visible = true;
51
87
  }
52
88
 
53
89
  /** Place the avatar at the player's feet, facing `yaw`, and animate it for `motion`. */
@@ -61,16 +97,18 @@ export class Player {
61
97
  if (!this.root) return;
62
98
  this.root.position.copy(feet);
63
99
  this.root.rotation.y = this.facing;
64
- if (this.isDefaultAvatar) {
100
+ if (this.external) {
101
+ if (motion) this.external.animate(motion);
102
+ } else if (this.isDefaultAvatar) {
65
103
  // Squash the default body while crouching instead of rebuilding it.
66
104
  this.root.scale.y = currentHeight / this.height;
67
- if (motion) animateDefaultAvatar(this.root.children[0], motion);
105
+ if (motion && this.baseBody) animateDefaultAvatar(this.baseBody, motion);
68
106
  }
69
107
  }
70
108
 
71
109
  setExpression(expression: AvatarExpression): void {
72
110
  this.expression = expression;
73
- if (this.root && this.isDefaultAvatar) setAvatarExpression(this.root.children[0], expression);
111
+ if (this.baseBody && this.isDefaultAvatar) setAvatarExpression(this.baseBody, expression);
74
112
  }
75
113
 
76
114
  setVisible(visible: boolean): void {
@@ -78,6 +116,7 @@ export class Player {
78
116
  }
79
117
 
80
118
  dispose(): void {
119
+ this.clearExternalBody();
81
120
  this.root?.traverse((child) => {
82
121
  if (child instanceof Mesh) {
83
122
  child.geometry.dispose();
@@ -11,6 +11,7 @@ import {
11
11
  TorusGeometry,
12
12
  Vector3,
13
13
  } from 'three';
14
+ import { withAvatarTicket } from '../avatar/handoff.js';
14
15
  import type { PortalMode, PortalOptions } from '../types.js';
15
16
 
16
17
  export interface ResolvedPortal extends PortalOptions {
@@ -107,14 +108,17 @@ export class PortalManager {
107
108
  }
108
109
  }
109
110
 
110
- /** Build the destination URL, carrying a back-reference to this world. */
111
+ /**
112
+ * Build the destination URL, carrying a back-reference to this world, and the
113
+ * visitor's avatar handoff ticket (in the fragment) if they arrived with one.
114
+ */
111
115
  export function buildTravelUrl(target: string): string {
112
116
  try {
113
117
  const url = new URL(target, window.location.href);
114
118
  if (!url.searchParams.has('from')) {
115
119
  url.searchParams.set('from', window.location.origin + window.location.pathname);
116
120
  }
117
- return url.toString();
121
+ return withAvatarTicket(url).toString();
118
122
  } catch {
119
123
  return target;
120
124
  }
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AvatarDescriptor } from './avatar/descriptor.js';
1
2
  import type { AvatarExpression } from './player/avatar.js';
2
3
  import type { Camera, Object3D, PerspectiveCamera, Scene, WebGLRenderer } from 'three';
3
4
 
@@ -78,6 +79,30 @@ export interface PlayerOptions {
78
79
  avatar?: Object3D | false;
79
80
  }
80
81
 
82
+ /**
83
+ * Let visitors bring their own character. Entirely optional: without it, or
84
+ * whenever resolving or loading fails, the player keeps the world's body.
85
+ *
86
+ * - `worldmesh`: the avatar the visitor picked in their WorldMesh Avatar
87
+ * Wallet, handed over through the URL fragment when they arrive from the
88
+ * hub or another world. The model loads from the avatar platform itself;
89
+ * this world never sees the visitor's account or platform credentials.
90
+ * - `descriptor`: an avatar the world resolved on its own.
91
+ */
92
+ export type AvatarOptions =
93
+ | {
94
+ source: 'worldmesh';
95
+ /** The WorldMesh hub that resolves handoff tickets. Defaults to https://worldmesh.net/. */
96
+ hubUrl?: string;
97
+ /** Refuse models larger than this many bytes. Defaults to 40 MB. */
98
+ maxBytes?: number;
99
+ }
100
+ | {
101
+ source: 'descriptor';
102
+ descriptor: AvatarDescriptor;
103
+ maxBytes?: number;
104
+ };
105
+
81
106
  export interface ViewOptions {
82
107
  mode?: ViewMode;
83
108
  /** Third-person boom length in metres. */
@@ -119,6 +144,17 @@ export interface UiOptions {
119
144
  controlsHint?: boolean;
120
145
  /** World name shown in the overlay. */
121
146
  title?: string;
147
+ /**
148
+ * Hide the click-to-enter panel until the visitor has entered once and then
149
+ * pressed Esc. The first screen stays clear; a click/tap still starts them.
150
+ */
151
+ deferLockPanel?: boolean;
152
+ /**
153
+ * Walking starts immediately. The keyboard, the on-screen joysticks and
154
+ * mouse look (with the cursor visible) work before any click, and Esc holds
155
+ * them until Continue. The first click captures the cursor for unlimited turning.
156
+ */
157
+ moveBeforeLock?: boolean;
122
158
  }
123
159
 
124
160
  /** Everything another peer would need to draw this player. Serializable on purpose. */
@@ -149,12 +185,27 @@ export interface WorldMeshEvents {
149
185
  /** E pressed with nothing else claiming it. */
150
186
  interact: { position: Vec3Tuple };
151
187
  respawn: { reason: 'fell' | 'manual' };
188
+ /** The visitor's own avatar replaced the default body. */
189
+ 'avatar:load': { descriptor: AvatarDescriptor };
190
+ /** An external avatar could not be shown; the default body stays. */
191
+ 'avatar:error': { descriptor: AvatarDescriptor | null; error: unknown };
192
+ /** People in this world including the visitor, or null while not connected. Only with `multiplayer`. */
193
+ players: { count: number | null };
194
+ }
195
+
196
+ /** Someone else's body, an upright cylinder the local player cannot walk through. */
197
+ export interface PeerBody {
198
+ /** Feet position in world space. */
199
+ x: number;
200
+ y: number;
201
+ z: number;
202
+ radius: number;
203
+ height: number;
152
204
  }
153
205
 
154
206
  /**
155
- * Multiplayer seam. Nothing in the runtime implements this yet — it exists so
156
- * the local player loop stays shaped like something a network can drive later
157
- * (peer-to-peer, a world-hosted server, or a WorldMesh signaling service).
207
+ * Multiplayer seam. `multiplayer` plugs in the built-in relay client; pass
208
+ * your own adapter here to network the world some other way.
158
209
  */
159
210
  export interface NetworkAdapter {
160
211
  /** Called once with a read-only view of the local world. */
@@ -163,6 +214,8 @@ export interface NetworkAdapter {
163
214
  sendLocalState?(state: PlayerState): void;
164
215
  /** Called by the adapter when a remote peer's state arrives. */
165
216
  onRemoteState?(peerId: string, state: PlayerState): void;
217
+ /** Where everyone else is standing right now. The local player is kept out of them. */
218
+ bodies?(): Iterable<PeerBody>;
166
219
  detach?(): void;
167
220
  }
168
221
 
@@ -180,6 +233,8 @@ export interface WorldMeshOptions {
180
233
  colliders?: Object3D[] | (() => Object3D[]);
181
234
  portals?: PortalOptions[];
182
235
  player?: PlayerOptions;
236
+ /** Show the visitor's own avatar. Off by default. */
237
+ avatar?: AvatarOptions;
183
238
  view?: ViewOptions;
184
239
  keymap?: Partial<Keymap>;
185
240
  movement?: Partial<MovementTuning>;
@@ -192,10 +247,21 @@ export interface WorldMeshOptions {
192
247
  autoResize?: boolean;
193
248
  /** Your per-frame world logic. Runs after movement, before rendering. */
194
249
  onUpdate?: (dt: number, world: WorldMeshHandle) => void;
195
- /** Reserved. Not used by the MVP. */
250
+ /**
251
+ * Show everyone else who is in this world. `true` uses the relay WorldMesh
252
+ * hosts; `{ server }` points at your own copy of workers/presence.
253
+ * Ignored when `network` is set.
254
+ */
255
+ multiplayer?: boolean | MultiplayerOptions;
256
+ /** A custom network layer. Replaces `multiplayer`. */
196
257
  network?: NetworkAdapter;
197
258
  }
198
259
 
260
+ export interface MultiplayerOptions {
261
+ /** WebSocket base URL of a presence relay, e.g. `wss://relay.example.com`. */
262
+ server?: string;
263
+ }
264
+
199
265
  export interface WorldMeshHandle {
200
266
  readonly scene: Scene;
201
267
  readonly camera: Camera;
@@ -203,6 +269,8 @@ export interface WorldMeshHandle {
203
269
  readonly abilities: Readonly<Abilities>;
204
270
  /** The avatar root. Move it and you move the player. */
205
271
  readonly avatar: Object3D | null;
272
+ /** The external avatar being shown, or null while the default body is. */
273
+ readonly avatarDescriptor: AvatarDescriptor | null;
206
274
 
207
275
  start(): void;
208
276
  stop(): void;
@@ -225,6 +293,11 @@ export interface WorldMeshHandle {
225
293
  on<K extends keyof WorldMeshEvents>(event: K, fn: (payload: WorldMeshEvents[K]) => void): () => void;
226
294
  off<K extends keyof WorldMeshEvents>(event: K, fn: (payload: WorldMeshEvents[K]) => void): void;
227
295
 
296
+ /** Show an external avatar. Resolves to false (default body kept) if it cannot be loaded. */
297
+ loadAvatar(descriptor: AvatarDescriptor): Promise<boolean>;
298
+ /** Go back to the world's default body. */
299
+ clearAvatar(): void;
300
+
228
301
  /** Refresh the collider list after the world adds geometry. */
229
302
  refreshColliders(): void;
230
303
  }
package/src/ui/overlay.ts CHANGED
@@ -47,6 +47,14 @@ const CSS = `
47
47
  -webkit-tap-highlight-color: transparent;
48
48
  }
49
49
  .wm-overlay[data-locked="true"] .wm-lock { display: none; }
50
+ /* First visit with deferLockPanel: keep the click target, hide the chrome. */
51
+ .wm-overlay[data-defer-lock="true"]:not([data-lock-ready="true"]) .wm-lock {
52
+ background: transparent;
53
+ backdrop-filter: none;
54
+ }
55
+ .wm-overlay[data-defer-lock="true"]:not([data-lock-ready="true"]) .wm-lock-content {
56
+ visibility: hidden;
57
+ }
50
58
  .wm-lock-content {
51
59
  margin: auto 0;
52
60
  width: 100%;
@@ -59,10 +67,14 @@ const CSS = `
59
67
  box-sizing: border-box;
60
68
  }
61
69
  .wm-lock-title {
70
+ width: 100%;
71
+ margin: 0;
72
+ padding: 0;
73
+ display: block;
74
+ text-align: center;
62
75
  font-size: clamp(22px, 5vw, 34px);
63
76
  font-weight: 600;
64
77
  letter-spacing: .01em;
65
- margin: 0;
66
78
  }
67
79
  .wm-lock-cta {
68
80
  font-size: 13.5px;
@@ -265,7 +277,9 @@ const KEY_LEGEND: [string, string][] = [
265
277
  export class Overlay {
266
278
  readonly root: HTMLDivElement;
267
279
  private prompt: HTMLDivElement;
280
+ private lock: HTMLDivElement;
268
281
  private onEnter: (touch?: boolean) => void;
282
+ private deferLockPanel: boolean;
269
283
 
270
284
  constructor(
271
285
  options: UiOptions & {
@@ -275,12 +289,15 @@ export class Overlay {
275
289
  ) {
276
290
  injectStyles();
277
291
  this.onEnter = options.onEnter;
292
+ this.deferLockPanel = options.deferLockPanel === true;
278
293
 
279
294
  const isTouch = isTouchDevice();
280
295
 
281
296
  this.root = document.createElement('div');
282
297
  this.root.className = 'wm-overlay';
283
- this.root.dataset.locked = 'false';
298
+ // Already walking: no click-to-enter layer. Esc is what brings the pause screen up.
299
+ this.root.dataset.locked = options.moveBeforeLock ? 'true' : 'false';
300
+ if (this.deferLockPanel) this.root.dataset.deferLock = 'true';
284
301
 
285
302
  if (options.crosshair !== false) {
286
303
  const crosshair = document.createElement('div');
@@ -290,6 +307,7 @@ export class Overlay {
290
307
 
291
308
  const lock = document.createElement('div');
292
309
  lock.className = 'wm-lock';
310
+ this.lock = lock;
293
311
 
294
312
  const content = document.createElement('div');
295
313
  content.className = 'wm-lock-content';
@@ -302,11 +320,10 @@ export class Overlay {
302
320
  const cta = document.createElement('button');
303
321
  cta.type = 'button';
304
322
  cta.className = 'wm-lock-cta';
305
- cta.textContent = isTouch ? 'Tap to enter' : 'Click to enter';
323
+ cta.textContent = 'Continue';
306
324
  content.appendChild(cta);
307
325
 
308
326
  const handleEnter = (e: Event) => {
309
- e.preventDefault();
310
327
  e.stopPropagation();
311
328
  const touchInitiated =
312
329
  ('pointerType' in e && (e as PointerEvent).pointerType === 'touch') ||
@@ -315,9 +332,18 @@ export class Overlay {
315
332
  this.onEnter(touchInitiated);
316
333
  };
317
334
 
335
+ // A click or tap on the button resumes. The Enter key does not: that key talks.
336
+ cta.addEventListener('keydown', (event) => {
337
+ if (event.key === 'Enter' || event.key === ' ') event.preventDefault();
338
+ });
318
339
  cta.addEventListener('click', handleEnter);
319
- cta.addEventListener('pointerup', handleEnter);
320
- lock.addEventListener('click', handleEnter);
340
+ lock.addEventListener('click', (event) => {
341
+ const target = event.target;
342
+ if (target instanceof Element && target.closest('button, a, input, textarea, select, label, .walk-pause-actions')) {
343
+ return;
344
+ }
345
+ handleEnter(event);
346
+ });
321
347
 
322
348
  if (options.controlsHint !== false) {
323
349
  const keys = document.createElement('div');
@@ -341,6 +367,7 @@ export class Overlay {
341
367
  }
342
368
  lock.appendChild(content);
343
369
  this.root.appendChild(lock);
370
+ if (this.deferLockPanel) this.hideLockChrome();
344
371
 
345
372
  this.prompt = document.createElement('div');
346
373
  this.prompt.className = 'wm-prompt';
@@ -365,14 +392,40 @@ export class Overlay {
365
392
 
366
393
  const hint = document.createElement('div');
367
394
  hint.className = 'wm-hint';
368
- hint.textContent = isTouch ? 'Tap ⏸ to pause' : 'ESC to release cursor';
395
+ hint.textContent = isTouch ? 'Tap ⏸ to open the menu' : 'Esc to open the menu';
369
396
  this.root.appendChild(hint);
370
397
 
371
398
  document.body.appendChild(this.root);
372
399
  }
373
400
 
401
+ /** The pause screen, with the control list, is on screen. */
402
+ isPaused(): boolean {
403
+ return this.root.dataset.lockReady === 'true' && this.root.dataset.locked !== 'true';
404
+ }
405
+
374
406
  setLocked(locked: boolean): void {
375
407
  this.root.dataset.locked = String(locked);
408
+ if (!locked && this.deferLockPanel) {
409
+ this.root.dataset.lockReady = 'true';
410
+ this.showLockChrome();
411
+ }
412
+ }
413
+
414
+ /** Keep the full-screen click target; strip the dimmer and copy. */
415
+ private hideLockChrome(): void {
416
+ this.lock.style.background = 'transparent';
417
+ this.lock.style.backdropFilter = 'none';
418
+ this.lock.style.setProperty('-webkit-backdrop-filter', 'none');
419
+ const content = this.lock.querySelector('.wm-lock-content');
420
+ if (content instanceof HTMLElement) content.style.visibility = 'hidden';
421
+ }
422
+
423
+ private showLockChrome(): void {
424
+ this.lock.style.background = '';
425
+ this.lock.style.backdropFilter = '';
426
+ this.lock.style.removeProperty('-webkit-backdrop-filter');
427
+ const content = this.lock.querySelector('.wm-lock-content');
428
+ if (content instanceof HTMLElement) content.style.visibility = '';
376
429
  }
377
430
 
378
431
  setPrompt(text: string | null): void {