@worldmesh/runtime 0.1.0 → 0.2.1

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
@@ -6,7 +6,14 @@ export interface InputOptions {
6
6
  /** The element that receives pointer lock. */
7
7
  element: HTMLElement;
8
8
  keymap?: Partial<Keymap>;
9
+ /** Keyboard and joysticks work before the cursor is captured. */
10
+ moveBeforeLock?: boolean;
9
11
  onPointerLockChange?: (locked: boolean) => void;
12
+ /**
13
+ * Escape toggles the pause screen. Return true when this press captures
14
+ * the mouse again, so the key's usual "show the cursor" action can be cancelled.
15
+ */
16
+ onEscape?: () => boolean | void;
10
17
  }
11
18
 
12
19
  /**
@@ -24,6 +31,7 @@ export class Input {
24
31
  locked = false;
25
32
 
26
33
  private element: HTMLElement;
34
+ private moveBeforeLock: boolean;
27
35
  private pressed = new Set<string>();
28
36
  /** Actions that went down this frame and have not been consumed yet. */
29
37
  private justPressed = new Set<InputAction>();
@@ -31,13 +39,16 @@ export class Input {
31
39
  private pendingDigit: number | null = null;
32
40
  private codeToActions = new Map<string, InputAction[]>();
33
41
  private onPointerLockChange?: (locked: boolean) => void;
42
+ private onEscape?: () => boolean | void;
34
43
  private touch?: TouchControls;
35
44
  private disposed = false;
36
45
 
37
46
  constructor(options: InputOptions) {
38
47
  this.element = options.element;
48
+ this.moveBeforeLock = options.moveBeforeLock === true;
39
49
  this.keymap = resolveKeymap(options.keymap);
40
50
  this.onPointerLockChange = options.onPointerLockChange;
51
+ this.onEscape = options.onEscape;
41
52
 
42
53
  for (const action of Object.keys(this.keymap) as InputAction[]) {
43
54
  for (const code of this.keymap[action]) {
@@ -56,6 +67,7 @@ export class Input {
56
67
  });
57
68
  }
58
69
 
70
+ window.addEventListener('keydown', this.handleEscape, true);
59
71
  window.addEventListener('keydown', this.handleKeyDown);
60
72
  window.addEventListener('keyup', this.handleKeyUp);
61
73
  window.addEventListener('blur', this.handleBlur);
@@ -63,6 +75,9 @@ export class Input {
63
75
  document.addEventListener('pointerlockerror', this.handlePointerLockError);
64
76
  this.element.addEventListener('mousemove', this.handleMouseMove);
65
77
  this.element.addEventListener('wheel', this.handleWheel, { passive: true });
78
+ this.element.addEventListener('pointerdown', this.handlePointerDown);
79
+ // The sticks are on screen from the start, so the first touch moves instead of "entering".
80
+ if (this.moveBeforeLock && isTouchDevice()) this.touch?.setVisible(true);
66
81
  }
67
82
 
68
83
  requestPointerLock(touchTriggered = false): void {
@@ -109,7 +124,15 @@ export class Input {
109
124
  if (document.pointerLockElement === this.element) {
110
125
  document.exitPointerLock();
111
126
  }
112
- this.setLocked(false);
127
+ if (this.locked) this.setLocked(false);
128
+ else {
129
+ // Never captured, but the pause screen is coming up: drop the sticks and any held keys.
130
+ this.pressed.clear();
131
+ this.justPressed.clear();
132
+ this.touch?.reset();
133
+ this.touch?.setVisible(false);
134
+ this.onPointerLockChange?.(false);
135
+ }
113
136
  }
114
137
 
115
138
  triggerAction(action: InputAction): void {
@@ -179,6 +202,7 @@ export class Input {
179
202
  dispose(): void {
180
203
  this.disposed = true;
181
204
  this.touch?.dispose();
205
+ window.removeEventListener('keydown', this.handleEscape, true);
182
206
  window.removeEventListener('keydown', this.handleKeyDown);
183
207
  window.removeEventListener('keyup', this.handleKeyUp);
184
208
  window.removeEventListener('blur', this.handleBlur);
@@ -186,10 +210,23 @@ export class Input {
186
210
  document.removeEventListener('pointerlockerror', this.handlePointerLockError);
187
211
  this.element.removeEventListener('mousemove', this.handleMouseMove);
188
212
  this.element.removeEventListener('wheel', this.handleWheel);
213
+ this.element.removeEventListener('pointerdown', this.handlePointerDown);
189
214
  this.pressed.clear();
190
215
  this.justPressed.clear();
191
216
  }
192
217
 
218
+ /**
219
+ * Escape toggles the pause screen. The browser's own Esc action is what
220
+ * puts the cursor back, so that is left alone. It is cancelled only when
221
+ * this press captures the mouse again, or the cursor would vanish immediately.
222
+ */
223
+ private handleEscape = (event: KeyboardEvent): void => {
224
+ if (event.key !== 'Escape' || event.repeat || this.disposed || !this.onEscape) return;
225
+ if (document.querySelector('dialog[open]')) return;
226
+ const relock = this.onEscape() === true;
227
+ if (relock) event.preventDefault();
228
+ };
229
+
193
230
  private handleKeyDown = (event: KeyboardEvent): void => {
194
231
  if (event.repeat) return;
195
232
  if (isTypingTarget(event.target)) return;
@@ -216,12 +253,34 @@ export class Input {
216
253
  this.justPressed.clear();
217
254
  };
218
255
 
256
+ /**
257
+ * A click captures the cursor for looking. Touch is left alone: the joysticks
258
+ * already have the finger, and they do not need a tap before they work.
259
+ */
260
+ private handlePointerDown = (event: PointerEvent): void => {
261
+ if (!this.moveBeforeLock || this.locked || this.disposed || event.button !== 0) return;
262
+ if (event.pointerType === 'touch' || event.pointerType === 'pen') return;
263
+ const target = event.target instanceof Element ? event.target : null;
264
+ if (target?.closest('button, a, input, textarea, select, label, dialog, .wm-lock, .walk-pause-actions')) return;
265
+ this.requestPointerLock();
266
+ };
267
+
268
+ /**
269
+ * Captured: every movement turns the camera. Before the first click, with
270
+ * moveBeforeLock, the visible cursor turns it too, so looking works from the start.
271
+ */
219
272
  private handleMouseMove = (event: MouseEvent): void => {
220
- if (!this.locked) return;
273
+ if (!this.locked && !this.hoverLook(event)) return;
221
274
  this.mouseDeltaX += event.movementX;
222
275
  this.mouseDeltaY += event.movementY;
223
276
  };
224
277
 
278
+ private hoverLook(event: MouseEvent): boolean {
279
+ if (!this.moveBeforeLock || this.disposed || isTouchDevice()) return false;
280
+ // A held button is a drag on something else, not a look.
281
+ return event.buttons === 0;
282
+ }
283
+
225
284
  private handleWheel = (event: WheelEvent): void => {
226
285
  if (!this.locked) return;
227
286
  this.wheelDelta += event.deltaY;
@@ -198,6 +198,11 @@ export class TouchControls {
198
198
  menuBtn.addEventListener('pointerdown', (e) => {
199
199
  e.stopPropagation();
200
200
  e.preventDefault();
201
+ });
202
+ // Open on click, not pointerdown: opening hides this button, and the tap's
203
+ // click would then land on the pause screen and resume straight away.
204
+ menuBtn.addEventListener('click', (e) => {
205
+ e.stopPropagation();
201
206
  this.onExit?.();
202
207
  });
203
208
  this.root.appendChild(menuBtn);
@@ -1,5 +1,8 @@
1
1
  import { Vector3 } from 'three';
2
2
  import { resolveAbilities } from '../abilities/abilities.js';
3
+ import { parseAvatarDescriptor, type AvatarDescriptor } from '../avatar/descriptor.js';
4
+ import { resolveWorldMeshAvatar, takeAvatarTicket } from '../avatar/handoff.js';
5
+ import { loadAvatarModel } from '../avatar/loader.js';
3
6
  import { CameraRig } from '../camera/cameraRig.js';
4
7
  import { Input } from '../controls/input.js';
5
8
  import { CollisionWorld } from '../movement/collision.js';
@@ -18,11 +21,13 @@ import type {
18
21
  import { Overlay } from '../ui/overlay.js';
19
22
  import { isTouchDevice } from '../controls/touch.js';
20
23
  import { Emitter } from './events.js';
24
+ import { DEFAULT_PRESENCE_SERVER, Presence } from '../net/presence.js';
21
25
 
22
26
  /** Never simulate more than this per substep, so a stall cannot tunnel the player. */
23
27
  const MAX_STEP = 1 / 60;
24
28
  /** Never simulate more than this per frame, so a backgrounded tab does not catch up violently. */
25
29
  const MAX_FRAME = 0.1;
30
+ const DEFAULT_HUB = 'https://worldmesh.net/';
26
31
 
27
32
  /**
28
33
  * Turn an ordinary Three.js scene into a WorldMesh world.
@@ -33,6 +38,9 @@ const MAX_FRAME = 0.1;
33
38
  */
34
39
  export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
35
40
  const { scene, camera, renderer } = options;
41
+ // Always take an avatar handoff ticket out of the address bar, even if this
42
+ // world shows no avatars: portals still carry it on to the next world.
43
+ const avatarTicket = takeAvatarTicket();
36
44
  const spawn = new Vector3(...(options.spawn ?? [0, 2, 0]));
37
45
  const abilities = resolveAbilities(options.abilities);
38
46
 
@@ -57,10 +65,19 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
57
65
  const input = new Input({
58
66
  element: renderer.domElement,
59
67
  keymap: options.keymap,
68
+ moveBeforeLock: options.ui?.moveBeforeLock === true,
60
69
  onPointerLockChange: (locked) => {
61
70
  overlay.setLocked(locked);
62
71
  events.emit('pointer:lock', { locked });
63
72
  },
73
+ onEscape: () => {
74
+ if (overlay.isPaused()) {
75
+ input.requestPointerLock();
76
+ return true;
77
+ }
78
+ input.exitPointerLock();
79
+ return false;
80
+ },
64
81
  });
65
82
 
66
83
  const cameraRig = new CameraRig({ ...options.view, camera, collision });
@@ -85,6 +102,8 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
85
102
  let elapsed = 0;
86
103
  let activePortal: ResolvedPortal | null = null;
87
104
  let disposed = false;
105
+ let avatarDescriptor: AvatarDescriptor | null = null;
106
+ let avatarLoad: AbortController | null = null;
88
107
 
89
108
  const handle: WorldMeshHandle = {
90
109
  scene,
@@ -92,6 +111,9 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
92
111
  renderer,
93
112
  abilities,
94
113
  avatar: player.root,
114
+ get avatarDescriptor() {
115
+ return avatarDescriptor;
116
+ },
95
117
 
96
118
  start,
97
119
  stop,
@@ -111,6 +133,9 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
111
133
  },
112
134
  travelTo,
113
135
 
136
+ loadAvatar,
137
+ clearAvatar,
138
+
114
139
  on: (event, fn) => events.on(event, fn),
115
140
  off: (event, fn) => events.off(event, fn),
116
141
 
@@ -124,10 +149,68 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
124
149
 
125
150
  if (options.autoStart !== false) start();
126
151
 
127
- options.network?.attach(handle);
152
+ const network = options.network ?? createMultiplayer();
153
+ network?.attach(handle);
154
+ if (network?.bodies) controller.bodies = () => network.bodies!();
155
+
156
+ startAvatar();
128
157
 
129
158
  return handle;
130
159
 
160
+ /** Brings in the visitor's own avatar, if the world asked for one. The default body stays until it is ready. */
161
+ function startAvatar(): void {
162
+ const config = options.avatar;
163
+ if (!config || !player.root) return;
164
+ if (config.source === 'descriptor') {
165
+ const descriptor = parseAvatarDescriptor(config.descriptor);
166
+ if (descriptor) void loadAvatar(descriptor);
167
+ else events.emit('avatar:error', { descriptor: null, error: new Error('Invalid avatar descriptor') });
168
+ return;
169
+ }
170
+ if (config.source !== 'worldmesh' || !avatarTicket) return;
171
+ resolveWorldMeshAvatar(config.hubUrl ?? DEFAULT_HUB, avatarTicket).then(
172
+ (descriptor) => {
173
+ if (descriptor && !disposed) void loadAvatar(descriptor);
174
+ },
175
+ (error) => events.emit('avatar:error', { descriptor: null, error }),
176
+ );
177
+ }
178
+
179
+ async function loadAvatar(descriptor: AvatarDescriptor): Promise<boolean> {
180
+ if (disposed || !player.root) return false;
181
+ avatarLoad?.abort();
182
+ const load = (avatarLoad = new AbortController());
183
+ try {
184
+ const loaded = await loadAvatarModel(descriptor, {
185
+ height,
186
+ maxBytes: options.avatar?.maxBytes,
187
+ signal: load.signal,
188
+ });
189
+ if (disposed || load.signal.aborted) {
190
+ loaded.dispose();
191
+ return false;
192
+ }
193
+ if (!player.setExternalBody(loaded)) return false;
194
+ avatarDescriptor = descriptor;
195
+ applyViewVisibility();
196
+ events.emit('avatar:load', { descriptor });
197
+ return true;
198
+ } catch (error) {
199
+ if (load.signal.aborted) return false;
200
+ console.warn('[worldmesh] Could not load the avatar; keeping the default body.', error);
201
+ events.emit('avatar:error', { descriptor, error });
202
+ return false;
203
+ } finally {
204
+ if (avatarLoad === load) avatarLoad = null;
205
+ }
206
+ }
207
+
208
+ function clearAvatar(): void {
209
+ avatarLoad?.abort();
210
+ player.clearExternalBody();
211
+ avatarDescriptor = null;
212
+ }
213
+
131
214
  function start(): void {
132
215
  if (running || disposed) return;
133
216
  running = true;
@@ -157,9 +240,12 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
157
240
  elapsed += dt;
158
241
 
159
242
  const look = input.readLook();
160
- if (input.locked) {
161
- cameraRig.look(look.dx, look.dy);
162
- cameraRig.zoom(look.wheel);
243
+ const playing = isPlaying();
244
+ if (playing) {
245
+ if (input.locked || isTouchDevice() || options.ui?.moveBeforeLock === true) {
246
+ cameraRig.look(look.dx, look.dy);
247
+ if (input.locked) cameraRig.zoom(look.wheel);
248
+ }
163
249
  if (input.consume('toggleView')) setViewMode(cameraRig.mode === 'first' ? 'third' : 'first');
164
250
  const digit = input.consumeDigit();
165
251
  const expression = digit === null ? null : expressionForDigit(digit);
@@ -170,7 +256,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
170
256
  let remaining = dt;
171
257
  while (remaining > 0) {
172
258
  const step = Math.min(remaining, MAX_STEP);
173
- controller.step(step, cameraRig.yaw, input.locked);
259
+ controller.step(step, cameraRig.yaw, playing);
174
260
  remaining -= step;
175
261
  }
176
262
 
@@ -191,6 +277,11 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
191
277
  events.emit('update', { dt, state: getState() });
192
278
  }
193
279
 
280
+ /** Walking, until Esc opens the pause screen. */
281
+ function isPlaying(): boolean {
282
+ return !overlay.isPaused() && (input.locked || options.ui?.moveBeforeLock === true);
283
+ }
284
+
194
285
  function updatePortals(): void {
195
286
  const nearest = portals.findActive(controller.position);
196
287
 
@@ -202,7 +293,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
202
293
 
203
294
  if (!nearest) {
204
295
  overlay.setPrompt(null);
205
- if (input.locked && input.consume('interact')) {
296
+ if (isPlaying() && input.consume('interact')) {
206
297
  events.emit('interact', { position: controller.position.toArray() as Vec3Tuple });
207
298
  }
208
299
  return;
@@ -216,7 +307,7 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
216
307
 
217
308
  const isTouch = isTouchDevice();
218
309
  overlay.setPrompt(isTouch ? `Tap to enter ${nearest.label}` : `Press E to enter ${nearest.label}`);
219
- if (input.locked && input.consume('interact')) activatePortal(nearest);
310
+ if (isPlaying() && input.consume('interact')) activatePortal(nearest);
220
311
  }
221
312
 
222
313
  function activatePortal(portal: ResolvedPortal): void {
@@ -298,12 +389,23 @@ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
298
389
  cameraRig.resize(width, viewHeight);
299
390
  }
300
391
 
392
+ function createMultiplayer(): Presence | undefined {
393
+ if (!options.multiplayer) return undefined;
394
+ const server = (options.multiplayer === true ? undefined : options.multiplayer.server) ?? DEFAULT_PRESENCE_SERVER;
395
+ return new Presence({
396
+ url: `${server.replace(/\/$/, '')}/world`,
397
+ scene,
398
+ onCount: (count) => events.emit('players', { count }),
399
+ });
400
+ }
401
+
301
402
  function dispose(): void {
302
403
  if (disposed) return;
303
404
  disposed = true;
304
405
  stop();
406
+ avatarLoad?.abort();
305
407
  window.removeEventListener('resize', handleResize);
306
- options.network?.detach?.();
408
+ network?.detach?.();
307
409
  input.dispose();
308
410
  overlay.dispose();
309
411
  portals.dispose();
package/src/index.ts CHANGED
@@ -16,22 +16,42 @@ export {
16
16
  getAvatarExpression,
17
17
  isAvatarExpression,
18
18
  setAvatarExpression,
19
+ setAvatarAppear,
20
+ setAvatarColor,
19
21
  } from './player/avatar.js';
20
22
  export type { AvatarExpression, AvatarMotion } from './player/avatar.js';
21
23
  export { PortalManager, buildTravelUrl, getReferringWorld } from './portals/portals.js';
24
+ export { isLoadableUrl, parseAvatarDescriptor } from './avatar/descriptor.js';
25
+ export type { AvatarDescriptor, AvatarFormat } from './avatar/descriptor.js';
26
+ export {
27
+ AVATAR_TICKET_PARAM,
28
+ AVATAR_TICKET_STORAGE_KEY,
29
+ getAvatarTicket,
30
+ resolveWorldMeshAvatar,
31
+ setAvatarTicket,
32
+ takeAvatarTicket,
33
+ withAvatarTicket,
34
+ } from './avatar/handoff.js';
35
+ export { loadAvatarModel } from './avatar/loader.js';
36
+ export type { LoadAvatarOptions, LoadedAvatar } from './avatar/loader.js';
22
37
  export { Overlay } from './ui/overlay.js';
23
38
  export { deserializePlayerState, serializePlayerState } from './net/adapter.js';
39
+ export { DEFAULT_PRESENCE_SERVER, Presence } from './net/presence.js';
40
+ export type { PresenceOptions } from './net/presence.js';
24
41
 
25
- export const WORLDMESH_VERSION = '0.1.0';
42
+ export const WORLDMESH_VERSION = '0.2.1';
26
43
  /** Bumped when the world-facing contract changes in an incompatible way. */
27
44
  export const WORLDMESH_PROTOCOL = 1;
28
45
 
29
46
  export type {
30
47
  Abilities,
48
+ AvatarOptions,
31
49
  InputAction,
32
50
  Keymap,
33
51
  MovementTuning,
52
+ MultiplayerOptions,
34
53
  NetworkAdapter,
54
+ PeerBody,
35
55
  PlayerOptions,
36
56
  PlayerState,
37
57
  PortalMode,
@@ -1,6 +1,6 @@
1
1
  import { Vector3 } from 'three';
2
2
  import type { Input } from '../controls/input.js';
3
- import type { Abilities, MovementTuning } from '../types.js';
3
+ import type { Abilities, MovementTuning, PeerBody } from '../types.js';
4
4
  import type { CollisionWorld } from './collision.js';
5
5
 
6
6
  export const DEFAULT_TUNING: MovementTuning = {
@@ -18,6 +18,13 @@ export const DEFAULT_TUNING: MovementTuning = {
18
18
  fallLimit: -60,
19
19
  };
20
20
 
21
+ /**
22
+ * How fast two people who start out inside each other (both arriving at the
23
+ * spawn point, say) drift apart, in m/s. Slow, so it reads as making room
24
+ * rather than a shove.
25
+ */
26
+ const SEPARATE_SPEED = 1.5;
27
+
21
28
  export interface ControllerOptions {
22
29
  input: Input;
23
30
  collision: CollisionWorld;
@@ -42,6 +49,8 @@ export class MovementController {
42
49
  flying = false;
43
50
  /** Current standing height, shrinks while crouching. */
44
51
  currentHeight: number;
52
+ /** Other people's bodies, which this player walks into rather than through. */
53
+ bodies: (() => Iterable<PeerBody>) | null = null;
45
54
 
46
55
  readonly tuning: MovementTuning;
47
56
 
@@ -197,18 +206,26 @@ export class MovementController {
197
206
 
198
207
  private applyMotion(dt: number): void {
199
208
  const horizontal = this.scratch.set(this.velocity.x * dt, 0, this.velocity.z * dt);
200
- const resolved = this.collision.resolveHorizontal(
209
+ let resolved = this.collision.resolveHorizontal(
201
210
  this.position,
202
211
  horizontal,
203
212
  this.radius,
204
213
  this.currentHeight,
205
214
  );
215
+ const walked = resolved.clone();
216
+ const apart = this.keepApart(resolved, dt);
217
+ // Stepping round someone can lead into a wall, so check the walls again.
218
+ if (apart !== 'clear') {
219
+ resolved = this.collision.resolveHorizontal(this.position, resolved, this.radius, this.currentHeight);
220
+ }
206
221
 
207
222
  // Feed the resolved slide back into velocity so we do not keep pushing
208
- // into a wall and accelerating along it.
223
+ // into a wall and accelerating along it. Drifting out of someone is not
224
+ // walking, so it does not carry on once clear of them.
209
225
  if (dt > 0) {
210
- this.velocity.x = resolved.x / dt;
211
- this.velocity.z = resolved.z / dt;
226
+ const moved = apart === 'separating' ? walked : resolved;
227
+ this.velocity.x = moved.x / dt;
228
+ this.velocity.z = moved.z / dt;
212
229
  }
213
230
  this.position.x += resolved.x;
214
231
  this.position.z += resolved.z;
@@ -245,4 +262,41 @@ export class MovementController {
245
262
  this.onGround = false;
246
263
  }
247
264
  }
265
+
266
+ /**
267
+ * Bend this step's horizontal `move` so the player does not end up inside
268
+ * anyone else. Walking into someone stops at their edge and slides round
269
+ * them; already overlapping, the gap may only grow. Says whether `move`
270
+ * changed, and whether that includes drifting out of someone.
271
+ */
272
+ private keepApart(move: Vector3, dt: number): 'clear' | 'blocked' | 'separating' {
273
+ if (!this.bodies) return 'clear';
274
+ const { x, y, z } = this.position;
275
+ let result: 'clear' | 'blocked' | 'separating' = 'clear';
276
+ for (const body of this.bodies()) {
277
+ // Only bodies at the same height block: jumping over someone is fine.
278
+ if (body.y >= y + this.currentHeight || y >= body.y + body.height) continue;
279
+ const reach = this.radius + body.radius;
280
+ const nowX = x - body.x;
281
+ const nowZ = z - body.z;
282
+ const now = Math.hypot(nowX, nowZ);
283
+ if (now >= reach + Math.hypot(move.x, move.z)) continue;
284
+ let awayX = nowX + move.x;
285
+ let awayZ = nowZ + move.z;
286
+ const next = Math.hypot(awayX, awayZ);
287
+ const floor = Math.min(reach, now + SEPARATE_SPEED * dt);
288
+ if (next >= floor) continue;
289
+ if (next < 1e-4) {
290
+ // Heading straight through their middle: step back the way we came, or any way at all.
291
+ awayX = now > 1e-4 ? nowX : 1;
292
+ awayZ = now > 1e-4 ? nowZ : 0;
293
+ }
294
+ // End the step `floor` away from them, in the direction we were headed.
295
+ const away = Math.hypot(awayX, awayZ);
296
+ move.x = (awayX / away) * floor - nowX;
297
+ move.z = (awayZ / away) * floor - nowZ;
298
+ if (result !== 'separating') result = now < reach ? 'separating' : 'blocked';
299
+ }
300
+ return result;
301
+ }
248
302
  }
@@ -1,20 +1,9 @@
1
1
  import type { NetworkAdapter, PlayerState } from '../types.js';
2
2
 
3
3
  /**
4
- * Multiplayer is intentionally NOT implemented. This file exists to pin down
5
- * the seam so the runtime stays easy to network later:
6
- *
7
- * - the local player is simulated from serializable `PlayerState`, so a peer's
8
- * state can drive an identical avatar with no movement-code changes;
9
- * - the runtime exposes `update(dt)` separately from rendering, so a fixed
10
- * network tick can drive it;
11
- * - nothing in movement/camera/portals reaches for a global connection.
12
- *
13
- * Expected eventual topologies, in order of how much WorldMesh has to host:
14
- * 1. peer-to-peer WebRTC, worlds exchange state directly;
15
- * 2. a multiplayer server run by the world's own creator;
16
- * 3. optional WorldMesh signaling/discovery only (no game traffic);
17
- * 4. an optional hosted fallback, last resort.
4
+ * The built-in implementation is `Presence` (presence.ts), enabled with the
5
+ * `multiplayer` option. Anything else that implements `NetworkAdapter` can be
6
+ * passed as `network` instead.
18
7
  */
19
8
  export type { NetworkAdapter, PlayerState };
20
9