@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.
- package/README.md +21 -2
- package/dist/avatar/descriptor.d.ts +27 -0
- package/dist/avatar/descriptor.d.ts.map +1 -0
- package/dist/avatar/descriptor.js +41 -0
- package/dist/avatar/descriptor.js.map +1 -0
- package/dist/avatar/handoff.d.ts +29 -0
- package/dist/avatar/handoff.d.ts.map +1 -0
- package/dist/avatar/handoff.js +92 -0
- package/dist/avatar/handoff.js.map +1 -0
- package/dist/avatar/loader.d.ts +26 -0
- package/dist/avatar/loader.d.ts.map +1 -0
- package/dist/avatar/loader.js +177 -0
- package/dist/avatar/loader.js.map +1 -0
- package/dist/controls/input.d.ts +25 -0
- package/dist/controls/input.d.ts.map +1 -1
- package/dist/controls/input.js +60 -2
- package/dist/controls/input.js.map +1 -1
- package/dist/controls/touch.d.ts.map +1 -1
- package/dist/controls/touch.js +5 -0
- package/dist/controls/touch.js.map +1 -1
- package/dist/core/worldmesh.d.ts.map +1 -1
- package/dist/core/worldmesh.js +114 -8
- package/dist/core/worldmesh.js.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/movement/controller.d.ts +10 -1
- package/dist/movement/controller.d.ts.map +1 -1
- package/dist/movement/controller.js +61 -4
- package/dist/movement/controller.js.map +1 -1
- package/dist/net/adapter.d.ts +3 -14
- package/dist/net/adapter.d.ts.map +1 -1
- package/dist/net/adapter.js.map +1 -1
- package/dist/net/presence.d.ts +68 -0
- package/dist/net/presence.d.ts.map +1 -0
- package/dist/net/presence.js +425 -0
- package/dist/net/presence.js.map +1 -0
- package/dist/player/avatar.d.ts +4 -0
- package/dist/player/avatar.d.ts.map +1 -1
- package/dist/player/avatar.js +97 -4
- package/dist/player/avatar.js.map +1 -1
- package/dist/player/player.d.ts +13 -0
- package/dist/player/player.d.ts.map +1 -1
- package/dist/player/player.js +44 -5
- package/dist/player/player.js.map +1 -1
- package/dist/portals/portals.d.ts +4 -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 +78 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/overlay.d.ts +7 -0
- package/dist/ui/overlay.d.ts.map +1 -1
- package/dist/ui/overlay.js +62 -7
- package/dist/ui/overlay.js.map +1 -1
- package/package.json +4 -1
- package/src/avatar/descriptor.ts +56 -0
- package/src/avatar/handoff.ts +91 -0
- package/src/avatar/loader.ts +184 -0
- package/src/controls/input.ts +61 -2
- package/src/controls/touch.ts +5 -0
- package/src/core/worldmesh.ts +110 -8
- package/src/index.ts +21 -1
- package/src/movement/controller.ts +59 -5
- package/src/net/adapter.ts +3 -14
- package/src/net/presence.ts +498 -0
- package/src/player/avatar.ts +109 -4
- package/src/player/player.ts +42 -3
- package/src/portals/portals.ts +6 -2
- package/src/types.ts +77 -4
- package/src/ui/overlay.ts +60 -7
package/src/controls/input.ts
CHANGED
|
@@ -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;
|
package/src/controls/touch.ts
CHANGED
|
@@ -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);
|
package/src/core/worldmesh.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
this.velocity.
|
|
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
|
}
|
package/src/net/adapter.ts
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import type { NetworkAdapter, PlayerState } from '../types.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|