@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,160 @@
1
+ /**
2
+ * The relay refuses a full room with HTTP 503 and the body "Room is full"
3
+ * before the WebSocket upgrade. Browsers do not expose that status on a failed
4
+ * handshake (close is usually 1006 with an empty reason). Node's `ws` package
5
+ * can surface it via `unexpected-response`. These helpers classify both.
6
+ */
7
+
8
+ /** Close / HTTP / error shapes that mean the relay refused a full room. */
9
+ export function isRoomFullSignal(signal: {
10
+ status?: number;
11
+ code?: number;
12
+ reason?: string;
13
+ message?: string;
14
+ body?: string;
15
+ } | null | undefined): boolean {
16
+ if (!signal) return false;
17
+ if (signal.status === 503) return true;
18
+ const text = [signal.reason, signal.message, signal.body].filter((part) => typeof part === 'string').join(' ');
19
+ return /room is full/i.test(text);
20
+ }
21
+
22
+ /** Copy on the full-screen overlay. Named rooms (the hub lobby) vs world rooms. */
23
+ export function roomFullLabel(wsUrl: string): string {
24
+ try {
25
+ if (/^\/room\//i.test(new URL(wsUrl).pathname)) return 'Lobby is full';
26
+ } catch {
27
+ // Fall through to the world wording.
28
+ }
29
+ return 'World is full';
30
+ }
31
+
32
+ export function presenceHttpUrl(wsUrl: string): string {
33
+ return wsUrl.replace(/^ws/i, 'http');
34
+ }
35
+
36
+ /** GET /occupancy query for this Presence URL (named room or this page's world). */
37
+ export function occupancyProbeUrl(wsUrl: string, pageOrigin?: string): string | null {
38
+ try {
39
+ const url = new URL(presenceHttpUrl(wsUrl));
40
+ const room = url.pathname.match(/^\/room\/([a-z0-9-]{1,64})$/i);
41
+ if (room) {
42
+ url.pathname = '/occupancy';
43
+ url.search = `rooms=${encodeURIComponent(room[1].toLowerCase())}`;
44
+ return url.toString();
45
+ }
46
+ if (/\/world\/?$/i.test(url.pathname)) {
47
+ const origin = pageOrigin || (typeof location !== 'undefined' ? location.origin : '');
48
+ if (!origin) return null;
49
+ url.pathname = '/occupancy';
50
+ url.search = `origins=${encodeURIComponent(origin)}`;
51
+ return url.toString();
52
+ }
53
+ } catch {
54
+ return null;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export function occupancyIsFull(payload: unknown, wsUrl: string, pageOrigin?: string): boolean {
60
+ if (!payload || typeof payload !== 'object') return false;
61
+ const data = payload as Record<string, unknown>;
62
+ let key: string | null = null;
63
+ try {
64
+ const path = new URL(wsUrl).pathname;
65
+ const room = path.match(/^\/room\/([a-z0-9-]{1,64})$/i);
66
+ if (room) key = `room:${room[1].toLowerCase()}`;
67
+ else {
68
+ const origin = (pageOrigin || (typeof location !== 'undefined' ? location.origin : '')).toLowerCase();
69
+ key = origin || null;
70
+ }
71
+ } catch {
72
+ return false;
73
+ }
74
+ if (!key) return false;
75
+ const entry = data[key];
76
+ if (!entry || typeof entry !== 'object') return false;
77
+ const { count, cap } = entry as { count?: unknown; cap?: unknown };
78
+ return atCapacity(count, cap);
79
+ }
80
+
81
+ function atCapacity(count: unknown, cap: unknown): boolean {
82
+ return (
83
+ typeof count === 'number' &&
84
+ typeof cap === 'number' &&
85
+ Number.isFinite(count) &&
86
+ Number.isFinite(cap) &&
87
+ cap > 0 &&
88
+ count >= cap
89
+ );
90
+ }
91
+
92
+ export type ProbeFetch = (
93
+ input: string,
94
+ init?: { method?: string; headers?: Record<string, string> },
95
+ ) => Promise<Response>;
96
+
97
+ /**
98
+ * The shared relay turns a world away for the rest of the UTC day once its
99
+ * room has used the free daily allowance: HTTP 429 with "allowance" in the
100
+ * body. A plain 429 ("Too many requests") is only a short rate limit.
101
+ */
102
+ export function isOverAllowanceSignal(signal: { status?: number; body?: string } | null | undefined): boolean {
103
+ return signal?.status === 429 && /allowance/i.test(signal.body ?? '');
104
+ }
105
+
106
+ /** Why a join was refused: the room is full, the world is past today's allowance, or neither (a network blip). */
107
+ export type RoomProbe = 'full' | 'allowance' | 'open';
108
+
109
+ /**
110
+ * After a handshake that never welcomed us, ask the relay why. Same-path GET
111
+ * (no Upgrade) is the browser stand-in for the refused upgrade; GET
112
+ * /occupancy covers older relays and Node callers that omit Origin.
113
+ */
114
+ export async function probeRoom(
115
+ wsUrl: string,
116
+ pageOrigin?: string,
117
+ fetchFn: ProbeFetch = fetch as ProbeFetch,
118
+ ): Promise<RoomProbe> {
119
+ const origin = pageOrigin || (typeof location !== 'undefined' ? location.origin : undefined);
120
+ const headers: Record<string, string> = { Accept: 'text/plain, application/json' };
121
+ if (origin) headers.Origin = origin;
122
+
123
+ try {
124
+ const direct = await fetchFn(presenceHttpUrl(wsUrl), { method: 'GET', headers });
125
+ const text = await direct.text();
126
+ if (isRoomFullSignal({ status: direct.status, body: text })) return 'full';
127
+ if (isOverAllowanceSignal({ status: direct.status, body: text })) return 'allowance';
128
+ if (direct.ok) {
129
+ try {
130
+ const json = JSON.parse(text) as { count?: unknown; cap?: unknown };
131
+ if (atCapacity(json.count, json.cap)) return 'full';
132
+ } catch {
133
+ // Not occupancy JSON (e.g. the old 426 body).
134
+ }
135
+ }
136
+ } catch {
137
+ // Fall through to /occupancy.
138
+ }
139
+
140
+ const occupancyUrl = occupancyProbeUrl(wsUrl, origin);
141
+ if (!occupancyUrl) return 'open';
142
+ try {
143
+ const res = await fetchFn(occupancyUrl, { method: 'GET', headers: { Accept: 'application/json' } });
144
+ const text = await res.text();
145
+ if (isRoomFullSignal({ status: res.status, body: text })) return 'full';
146
+ if (!res.ok) return 'open';
147
+ return occupancyIsFull(JSON.parse(text), wsUrl, origin) ? 'full' : 'open';
148
+ } catch {
149
+ return 'open';
150
+ }
151
+ }
152
+
153
+ /** probeRoom, answering only whether the room is full. */
154
+ export async function probeRoomFull(
155
+ wsUrl: string,
156
+ pageOrigin?: string,
157
+ fetchFn: ProbeFetch = fetch as ProbeFetch,
158
+ ): Promise<boolean> {
159
+ return (await probeRoom(wsUrl, pageOrigin, fetchFn)) === 'full';
160
+ }
@@ -14,6 +14,7 @@ import {
14
14
  SplineCurve,
15
15
  SRGBColorSpace,
16
16
  Vector2,
17
+ Vector3,
17
18
  type BufferGeometry,
18
19
  type Object3D,
19
20
  } from 'three';
@@ -100,20 +101,38 @@ function toonSteps(shadow: string, light: string): CanvasTexture {
100
101
  }
101
102
 
102
103
  const APPEAR_KEY = 'worldmeshAppear';
104
+ const APPEAR_AXIS_KEY = 'worldmeshAppearAxis';
105
+ const UP = new Vector3(0, 1, 0);
103
106
 
104
107
  /**
105
- * Scatter the surface into points that fill in as `uFill` goes from 0 to 1.
106
- * Materials on one figure share a uniform so the body and face appear together.
108
+ * Warp the surface along an axis (up and down unless told otherwise) and fade
109
+ * it in as `uFill` goes from 0 to 1. Materials on one figure share uniforms so
110
+ * the body and face appear together. Runs after any shader changes the
111
+ * material already makes (a VRM's MToon has its own).
107
112
  */
108
- function attachAppear(material: Material, shared?: { value: number }): { value: number } {
109
- const fill = shared ?? { value: 1 };
113
+ function attachAppear(
114
+ material: Material,
115
+ shared?: { fill: { value: number }; axis: { value: Vector3 } },
116
+ ): { fill: { value: number }; axis: { value: Vector3 } } {
117
+ const fill = shared?.fill ?? { value: 1 };
118
+ const axis = shared?.axis ?? { value: UP.clone() };
110
119
  material.userData[APPEAR_KEY] = fill;
111
- material.customProgramCacheKey = () => 'worldmesh-appear';
112
- material.onBeforeCompile = (shader) => {
120
+ material.userData[APPEAR_AXIS_KEY] = axis;
121
+ const ownKey = material.customProgramCacheKey;
122
+ const ownCompile = material.onBeforeCompile;
123
+ material.customProgramCacheKey = () => `${ownKey.call(material)}|worldmesh-appear`;
124
+ material.onBeforeCompile = (shader, renderer) => {
125
+ ownCompile.call(material, shader, renderer);
126
+ // A shader without these hooks is left as it is: it just appears without the warp.
127
+ const hooks = ['#include <common>', '#include <begin_vertex>'].every((hook) => shader.vertexShader.includes(hook)) &&
128
+ ['#include <common>', '#include <dithering_fragment>'].every((hook) => shader.fragmentShader.includes(hook));
129
+ if (!hooks) return;
113
130
  shader.uniforms.uFill = fill;
131
+ shader.uniforms.uAppearAxis = axis;
114
132
  const appearFns = `
115
133
  varying vec3 vAppearPos;
116
134
  uniform float uFill;
135
+ uniform vec3 uAppearAxis;
117
136
  float appearHash(vec3 p) {
118
137
  p = fract(p * vec3(443.897, 441.423, 437.195));
119
138
  p += dot(p, p.yzx + 19.19);
@@ -122,17 +141,26 @@ float appearHash(vec3 p) {
122
141
  float appearNoise(vec3 p) {
123
142
  return appearHash(p) * 0.65 + appearHash(p * 2.7 + 4.2) * 0.35;
124
143
  }`;
144
+ // The shift is picked from the rest pose but added after skinning: added
145
+ // before, every bone would turn it with its own rotation (a T-posed arm
146
+ // hanging down throws it sideways) instead of it running along the axis.
147
+ const shift = 'transformed += (inverse(modelMatrix) * vec4(uAppearAxis * appearShift, 0.0)).xyz;';
148
+ const skinned = shader.vertexShader.includes('#include <skinning_vertex>');
125
149
  shader.vertexShader = shader.vertexShader
126
150
  .replace('#include <common>', `#include <common>\n${appearFns}`)
127
151
  .replace(
128
152
  '#include <begin_vertex>',
129
153
  `#include <begin_vertex>
130
- float appearWarp = 1.0 - uFill;
154
+ // Eased so the warp stays strong for longer before it settles.
155
+ float appearWarp = pow(1.0 - uFill, 0.6);
131
156
  vec3 appearWorld = (modelMatrix * vec4(position, 1.0)).xyz;
132
- float appearShift = (appearNoise(appearWorld * 5.5) * 2.0 - 1.0) * 0.38 * appearWarp;
133
- transformed += (inverse(modelMatrix) * vec4(0.0, appearShift, 0.0, 0.0)).xyz;
134
- vAppearPos = appearWorld;`,
157
+ float appearShift = (appearNoise(appearWorld * 5.5) * 2.0 - 1.0) * 1.1 * appearWarp;
158
+ vAppearPos = appearWorld;
159
+ ${skinned ? '' : shift}`,
135
160
  );
161
+ if (skinned) {
162
+ shader.vertexShader = shader.vertexShader.replace('#include <skinning_vertex>', `#include <skinning_vertex>\n${shift}`);
163
+ }
136
164
  shader.fragmentShader = shader.fragmentShader
137
165
  .replace(
138
166
  '#include <common>',
@@ -141,11 +169,29 @@ ${appearFns}`,
141
169
  )
142
170
  .replace(
143
171
  '#include <dithering_fragment>',
144
- `if (uFill < 0.999 && appearHash(floor(vAppearPos * 70.0)) > uFill) discard;
145
- #include <dithering_fragment>`,
172
+ `#include <dithering_fragment>
173
+ // Fades from invisible to solid over the whole warp.
174
+ gl_FragColor.a *= uFill * uFill * (3.0 - 2.0 * uFill);`,
146
175
  );
147
176
  };
148
- return fill;
177
+ return { fill, axis };
178
+ }
179
+
180
+ /**
181
+ * Give a loaded avatar (VRM or glTF) the same appear warp as the default body,
182
+ * so setAvatarAppear can bring it in. Settled (solid) to begin with.
183
+ */
184
+ export function attachAvatarAppear(root: Object3D): void {
185
+ let shared: { fill: { value: number }; axis: { value: Vector3 } } | undefined;
186
+ root.traverse((child) => {
187
+ const mesh = child as Mesh;
188
+ if (!mesh.isMesh) return;
189
+ for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
190
+ // Meshes can share a material: warp it once.
191
+ if (material.userData[APPEAR_KEY]) continue;
192
+ shared = attachAppear(material, shared);
193
+ }
194
+ });
149
195
  }
150
196
 
151
197
  /** Tint a default avatar. Custom bodies are left alone. */
@@ -164,8 +210,11 @@ export function setAvatarColor(root: Object3D, color: string): boolean {
164
210
  return found;
165
211
  }
166
212
 
167
- /** Drive the point-fill on a default avatar. Returns false for anything else. */
168
- export function setAvatarAppear(root: Object3D, amount: number): boolean {
213
+ /**
214
+ * Drive the point-fill on a default avatar or a loaded one. The surface warps
215
+ * along `axis` (a world direction; up by default). Returns false for anything else.
216
+ */
217
+ export function setAvatarAppear(root: Object3D, amount: number, axis: { x: number; y: number; z: number } = UP): boolean {
169
218
  let found = false;
170
219
  root.traverse((child) => {
171
220
  const mesh = child as Mesh;
@@ -175,6 +224,15 @@ export function setAvatarAppear(root: Object3D, amount: number): boolean {
175
224
  const fill = material.userData[APPEAR_KEY] as { value: number } | undefined;
176
225
  if (!fill) continue;
177
226
  fill.value = amount;
227
+ (material.userData[APPEAR_AXIS_KEY] as { value: Vector3 } | undefined)?.value.set(axis.x, axis.y, axis.z);
228
+ // Blend only while fading in, so a settled figure draws as before.
229
+ // The face decal is always see-through; keep that.
230
+ material.userData.appearTransparent ??= material.transparent;
231
+ const blend = amount < 0.999 || material.userData.appearTransparent === true;
232
+ if (material.transparent !== blend) {
233
+ material.transparent = blend;
234
+ material.needsUpdate = true;
235
+ }
178
236
  found = true;
179
237
  }
180
238
  });
@@ -300,11 +358,13 @@ export function getAvatarExpression(avatar: Object3D): AvatarExpression | null {
300
358
  /**
301
359
  * Swing the limbs of an avatar made by `createDefaultAvatar`. Call once per
302
360
  * frame with how fast it is moving; anything else is ignored, so it is safe
303
- * to call on custom avatars too.
361
+ * to call on custom avatars too. Returns true on the frame a foot lands,
362
+ * false otherwise, and null when `avatar` is not a default avatar.
304
363
  */
305
- export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): void {
364
+ export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): boolean | null {
306
365
  const rig = avatar.userData[RIG_KEY] as AvatarRig | undefined;
307
- if (!rig || motion.dt <= 0) return;
366
+ if (!rig) return null;
367
+ if (motion.dt <= 0) return false;
308
368
 
309
369
  const blend = 1 - Math.exp(-motion.dt * 10);
310
370
  const walking = motion.grounded ? MathUtils.clamp(motion.speed / 4.5, 0, 1) : 0;
@@ -312,6 +372,7 @@ export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): vo
312
372
  rig.air += ((motion.grounded ? 0 : 1) - rig.air) * blend;
313
373
 
314
374
  // Steps get longer, not just faster, as speed rises.
375
+ const before = footfalls(rig.phase);
315
376
  rig.phase += motion.dt * (4 + motion.speed * 1.2) * (rig.stride > 0.01 ? 1 : 0);
316
377
  const swing = Math.sin(rig.phase) * 0.75 * rig.stride;
317
378
 
@@ -324,6 +385,8 @@ export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): vo
324
385
 
325
386
  rig.torso.position.y = Math.abs(Math.cos(rig.phase)) * 0.045 * rig.stride;
326
387
  rig.head.rotation.z = Math.sin(rig.phase) * 0.04 * rig.stride;
388
+ // A foot lands where the legs are furthest apart and the body is lowest.
389
+ const landed = motion.grounded && rig.stride > 0.15 && footfalls(rig.phase) !== before;
327
390
 
328
391
  rig.blinkTimer -= motion.dt;
329
392
  if (rig.blinkTimer <= 0) {
@@ -331,6 +394,12 @@ export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): vo
331
394
  rig.blinkTimer = rig.blinking ? 0.12 : nextBlink();
332
395
  rig.face.map = faceTexture(rig.expression, rig.blinking);
333
396
  }
397
+ return landed;
398
+ }
399
+
400
+ /** How many footfalls a walk cycle at `phase` has passed: one per half turn, offset to the widest stance. */
401
+ function footfalls(phase: number): number {
402
+ return Math.floor((phase - Math.PI / 2) / Math.PI);
334
403
  }
335
404
 
336
405
  function nextBlink(): number {
@@ -1,6 +1,7 @@
1
1
  import { Group, Mesh, Object3D, type Vector3 } from 'three';
2
2
  import type { LoadedAvatar } from '../avatar/loader.js';
3
3
  import type { PlayerOptions } from '../types.js';
4
+ import { setFigureStroke } from './stroke.js';
4
5
  import {
5
6
  animateDefaultAvatar,
6
7
  createDefaultAvatar,
@@ -51,6 +52,8 @@ export class Player {
51
52
  this.isDefaultAvatar = !options.avatar;
52
53
 
53
54
  const body = options.avatar ?? createDefaultAvatar(options.height);
55
+ // The default figure always wears its ink outline, in the hub and every world alike.
56
+ if (this.isDefaultAvatar) setFigureStroke(body, true);
54
57
  this.root.add(body as Object3D);
55
58
  this.baseBody = body;
56
59
  }
@@ -86,24 +89,30 @@ export class Player {
86
89
  if (this.baseBody) this.baseBody.visible = true;
87
90
  }
88
91
 
89
- /** Place the avatar at the player's feet, facing `yaw`, and animate it for `motion`. */
90
- sync(feet: Vector3, yaw: number, currentHeight: number, motion?: AvatarMotion, freeLook = false): void {
92
+ /**
93
+ * Place the avatar at the player's feet, facing `yaw`, and animate it for `motion`.
94
+ * Says whether a foot landed this frame, or null when the avatar's walk is
95
+ * not one the runtime animates and so cannot tell.
96
+ */
97
+ sync(feet: Vector3, yaw: number, currentHeight: number, motion?: AvatarMotion, freeLook = false): boolean | null {
91
98
  if (!freeLook) this.facing = yaw;
92
99
  else if (motion && motion.speed > 0.3 && motion.heading !== undefined) {
93
100
  let delta = motion.heading - this.facing;
94
101
  delta = Math.atan2(Math.sin(delta), Math.cos(delta));
95
102
  this.facing += delta * (1 - Math.exp(-12 * motion.dt));
96
103
  }
97
- if (!this.root) return;
104
+ if (!this.root) return null;
98
105
  this.root.position.copy(feet);
99
106
  this.root.rotation.y = this.facing;
100
107
  if (this.external) {
101
108
  if (motion) this.external.animate(motion);
109
+ return null;
102
110
  } else if (this.isDefaultAvatar) {
103
111
  // Squash the default body while crouching instead of rebuilding it.
104
112
  this.root.scale.y = currentHeight / this.height;
105
- if (motion && this.baseBody) animateDefaultAvatar(this.baseBody, motion);
113
+ if (motion && this.baseBody) return animateDefaultAvatar(this.baseBody, motion);
106
114
  }
115
+ return null;
107
116
  }
108
117
 
109
118
  setExpression(expression: AvatarExpression): void {
@@ -0,0 +1,93 @@
1
+ import { BackSide, Mesh, MeshBasicMaterial, MeshToonMaterial, type Material, type Object3D } from 'three';
2
+
3
+ /** How far the shell sits outside the body, in metres. Thick enough to read as ink. */
4
+ const STROKE_WIDTH = 0.05;
5
+ /** Same ink as the door frames. Shown in both themes. */
6
+ const STROKE_COLOR = 0x1c1c1c;
7
+
8
+ /**
9
+ * A cartoon outline around the default white figure. Each body part gets a
10
+ * slightly larger shell of its back faces, so the ink shows only at the
11
+ * silhouette. The drawn-on face is left alone.
12
+ */
13
+ export function setFigureStroke(root: Object3D | null, enabled: boolean): void {
14
+ if (!root) return;
15
+ const strokes: Mesh[] = [];
16
+ root.traverse((child) => {
17
+ if (child instanceof Mesh && child.userData.stroke === true) strokes.push(child);
18
+ });
19
+ if (!enabled) {
20
+ const materials = new Set<Material>();
21
+ for (const mesh of strokes) {
22
+ if (!Array.isArray(mesh.material)) materials.add(mesh.material);
23
+ mesh.removeFromParent();
24
+ }
25
+ for (const material of materials) material.dispose();
26
+ return;
27
+ }
28
+ if (strokes.length > 0) return;
29
+
30
+ const sources: Mesh[] = [];
31
+ root.traverse((child) => {
32
+ if (!(child instanceof Mesh) || child.userData.stroke === true) return;
33
+ if (!(child.material instanceof MeshToonMaterial)) return;
34
+ sources.push(child);
35
+ });
36
+ if (sources.length === 0) return;
37
+
38
+ const sample = sources[0].material as MeshToonMaterial;
39
+ const fading = sample.transparent;
40
+ const material = new MeshBasicMaterial({
41
+ color: STROKE_COLOR,
42
+ side: BackSide,
43
+ toneMapped: false,
44
+ transparent: fading,
45
+ opacity: fading ? sample.opacity : 1,
46
+ depthWrite: sample.depthWrite,
47
+ });
48
+ material.userData.stroke = true;
49
+ material.polygonOffset = true;
50
+ material.polygonOffsetFactor = 1;
51
+ material.polygonOffsetUnits = 1;
52
+ material.customProgramCacheKey = () => 'worldmesh-stroke';
53
+ material.onBeforeCompile = (shader) => {
54
+ shader.vertexShader = shader.vertexShader.replace(
55
+ '#include <begin_vertex>',
56
+ `#include <begin_vertex>\ntransformed += normalize(normal) * ${STROKE_WIDTH.toFixed(3)};`,
57
+ );
58
+ };
59
+
60
+ for (const source of sources) {
61
+ const stroke = new Mesh(source.geometry, material);
62
+ stroke.name = 'worldmesh:stroke';
63
+ stroke.userData.stroke = true;
64
+ stroke.position.copy(source.position);
65
+ stroke.quaternion.copy(source.quaternion);
66
+ stroke.scale.copy(source.scale);
67
+ stroke.castShadow = false;
68
+ stroke.receiveShadow = false;
69
+ stroke.raycast = () => {};
70
+ source.parent?.add(stroke);
71
+ }
72
+ }
73
+
74
+ /** Fade the outline with the figure. Shared across every part, so one write is enough. */
75
+ export function setStrokeOpacity(root: Object3D | null, amount: number): void {
76
+ if (!root) return;
77
+ const solid = amount >= 0.999;
78
+ let material: MeshBasicMaterial | null = null;
79
+ root.traverse((child) => {
80
+ if (material || !(child instanceof Mesh) || child.userData.stroke !== true) return;
81
+ if (child.material instanceof MeshBasicMaterial) material = child.material;
82
+ });
83
+ const stroke = material as MeshBasicMaterial | null;
84
+ if (!stroke) return;
85
+ stroke.transparent = !solid;
86
+ stroke.opacity = amount;
87
+ stroke.depthWrite = solid;
88
+ stroke.needsUpdate = true;
89
+ }
90
+
91
+ export function isStrokeMaterial(material: Material): boolean {
92
+ return material.userData.stroke === true;
93
+ }
@@ -12,6 +12,7 @@ import {
12
12
  Vector3,
13
13
  } from 'three';
14
14
  import { withAvatarTicket } from '../avatar/handoff.js';
15
+ import { withView } from '../camera/viewHandoff.js';
15
16
  import type { PortalMode, PortalOptions } from '../types.js';
16
17
 
17
18
  export interface ResolvedPortal extends PortalOptions {
@@ -110,7 +111,7 @@ export class PortalManager {
110
111
 
111
112
  /**
112
113
  * 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
+ * visitor's avatar handoff ticket and camera view (in the fragment) if any.
114
115
  */
115
116
  export function buildTravelUrl(target: string): string {
116
117
  try {
@@ -118,7 +119,9 @@ export function buildTravelUrl(target: string): string {
118
119
  if (!url.searchParams.has('from')) {
119
120
  url.searchParams.set('from', window.location.origin + window.location.pathname);
120
121
  }
121
- return withAvatarTicket(url).toString();
122
+ // A world's own fragment (e.g. a hash route) is left alone.
123
+ if (url.hash) return url.toString();
124
+ return withView(withAvatarTicket(url)).toString();
122
125
  } catch {
123
126
  return target;
124
127
  }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AvatarDescriptor } from './avatar/descriptor.js';
1
+ import type { AvatarDescriptor, SignedAvatarShare } from './avatar/descriptor.js';
2
2
  import type { AvatarExpression } from './player/avatar.js';
3
3
  import type { Camera, Object3D, PerspectiveCamera, Scene, WebGLRenderer } from 'three';
4
4
 
@@ -134,7 +134,7 @@ export interface PortalOptions {
134
134
  }
135
135
 
136
136
  export interface UiOptions {
137
- /** Crosshair dot in first person. */
137
+ /** Crosshair dot in the middle of the screen. Off unless set to true. */
138
138
  crosshair?: boolean;
139
139
  /** Bottom-left WorldMesh badge linking back to the hub. */
140
140
  badge?: boolean;
@@ -191,6 +191,12 @@ export interface WorldMeshEvents {
191
191
  'avatar:error': { descriptor: AvatarDescriptor | null; error: unknown };
192
192
  /** People in this world including the visitor, or null while not connected. Only with `multiplayer`. */
193
193
  players: { count: number | null };
194
+ /** The presence relay refused the join because the room is at capacity. */
195
+ 'room:full': { full: boolean };
196
+ /** An immersive-vr session started. */
197
+ 'vr:enter': Record<string, never>;
198
+ /** The immersive-vr session ended. */
199
+ 'vr:exit': Record<string, never>;
194
200
  }
195
201
 
196
202
  /** Someone else's body, an upright cylinder the local player cannot walk through. */
@@ -205,17 +211,24 @@ export interface PeerBody {
205
211
 
206
212
  /**
207
213
  * Multiplayer seam. `multiplayer` plugs in the built-in relay client; pass
208
- * your own adapter here to network the world some other way.
214
+ * your own adapter as `network` to network the world some other way
215
+ * (docs/compatibility.md, "Your own network layer"). The runtime calls only
216
+ * `attach`, `bodies` and `detach`; the adapter does everything else itself.
209
217
  */
210
218
  export interface NetworkAdapter {
211
- /** Called once with a read-only view of the local world. */
219
+ /**
220
+ * Called once when the world goes online, with the world's handle. Listen
221
+ * to its `update` event for the local player's state every frame. In a
222
+ * hub's live doorway the world stays offline until the visitor walks in.
223
+ */
212
224
  attach(world: WorldMeshHandle): void;
213
- /** Called with the local player's state at the network tick rate. */
225
+ /** Never called by the runtime: a conventional name for the adapter's own send, called from `update`. */
214
226
  sendLocalState?(state: PlayerState): void;
215
- /** Called by the adapter when a remote peer's state arrives. */
227
+ /** Never called by the runtime: a conventional name for the adapter's own receive. */
216
228
  onRemoteState?(peerId: string, state: PlayerState): void;
217
- /** Where everyone else is standing right now. The local player is kept out of them. */
229
+ /** Where everyone else is standing right now, asked every step. The local player is kept out of them. */
218
230
  bodies?(): Iterable<PeerBody>;
231
+ /** Called from `world.dispose()`. Disconnect and remove what the adapter added to the scene. */
219
232
  detach?(): void;
220
233
  }
221
234
 
@@ -253,8 +266,15 @@ export interface WorldMeshOptions {
253
266
  * Ignored when `network` is set.
254
267
  */
255
268
  multiplayer?: boolean | MultiplayerOptions;
269
+ /** Play footstep sounds as the player walks. Off by default. */
270
+ footsteps?: boolean;
256
271
  /** A custom network layer. Replaces `multiplayer`. */
257
272
  network?: NetworkAdapter;
273
+ /**
274
+ * Offer immersive VR through the shared Enter VR control. Defaults to true.
275
+ * The button only appears when the browser can start an `immersive-vr` session.
276
+ */
277
+ vr?: boolean;
258
278
  }
259
279
 
260
280
  export interface MultiplayerOptions {
@@ -285,19 +305,42 @@ export interface WorldMeshHandle {
285
305
 
286
306
  setViewMode(mode: ViewMode): void;
287
307
  getViewMode(): ViewMode;
308
+ /** How far back the third-person camera sits, in metres. */
309
+ getCameraDistance(): number;
310
+ /** Move the third-person camera in or out (0.4 m at the closest); it follows at once when closer. */
311
+ setCameraDistance(distance: number): void;
288
312
 
289
313
  addPortal(portal: PortalOptions): void;
290
314
  /** Navigate to another WorldMesh world, carrying a `from` back-reference. */
291
315
  travelTo(url: string): void;
316
+ /** Free the cursor for leaving the page, without bringing up the pause screen. */
317
+ releasePointer(): void;
292
318
 
293
319
  on<K extends keyof WorldMeshEvents>(event: K, fn: (payload: WorldMeshEvents[K]) => void): () => void;
294
320
  off<K extends keyof WorldMeshEvents>(event: K, fn: (payload: WorldMeshEvents[K]) => void): void;
295
321
 
296
- /** Show an external avatar. Resolves to false (default body kept) if it cannot be loaded. */
297
- loadAvatar(descriptor: AvatarDescriptor): Promise<boolean>;
322
+ /**
323
+ * Show an external avatar. Resolves to false (default body kept) if it
324
+ * cannot be loaded. Pass `data` (downloadAvatarModel) if the model file is
325
+ * already downloaded, and it is not fetched again.
326
+ */
327
+ loadAvatar(descriptor: AvatarDescriptor, data?: ArrayBuffer): Promise<boolean>;
298
328
  /** Go back to the world's default body. */
299
329
  clearAvatar(): void;
330
+ /**
331
+ * The link other players may download this visitor's avatar from, signed by
332
+ * the hub and renewed before it expires; null for the default body or an
333
+ * avatar that may not be shown to others. For a custom `network` to send.
334
+ */
335
+ getSharedAvatar(): SignedAvatarShare | null;
300
336
 
301
337
  /** Refresh the collider list after the world adds geometry. */
302
338
  refreshColliders(): void;
339
+
340
+ /** Start an immersive-vr session. Resolves false if the browser refuses. */
341
+ enterVR(): Promise<boolean>;
342
+ /** End the immersive-vr session, if one is running. */
343
+ exitVR(): Promise<void>;
344
+ /** True while a WebXR immersive-vr session is presenting. */
345
+ isVR(): boolean;
303
346
  }