@worldmesh/runtime 0.1.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/abilities/abilities.d.ts +9 -0
  4. package/dist/abilities/abilities.d.ts.map +1 -0
  5. package/dist/abilities/abilities.js +25 -0
  6. package/dist/abilities/abilities.js.map +1 -0
  7. package/dist/camera/cameraRig.d.ts +39 -0
  8. package/dist/camera/cameraRig.d.ts.map +1 -0
  9. package/dist/camera/cameraRig.js +88 -0
  10. package/dist/camera/cameraRig.js.map +1 -0
  11. package/dist/controls/input.d.ts +64 -0
  12. package/dist/controls/input.d.ts.map +1 -0
  13. package/dist/controls/input.js +236 -0
  14. package/dist/controls/input.js.map +1 -0
  15. package/dist/controls/keymap.d.ts +8 -0
  16. package/dist/controls/keymap.d.ts.map +1 -0
  17. package/dist/controls/keymap.js +21 -0
  18. package/dist/controls/keymap.js.map +1 -0
  19. package/dist/controls/touch.d.ts +43 -0
  20. package/dist/controls/touch.d.ts.map +1 -0
  21. package/dist/controls/touch.js +329 -0
  22. package/dist/controls/touch.js.map +1 -0
  23. package/dist/core/events.d.ts +9 -0
  24. package/dist/core/events.d.ts.map +1 -0
  25. package/dist/core/events.js +27 -0
  26. package/dist/core/events.js.map +1 -0
  27. package/dist/core/worldmesh.d.ts +10 -0
  28. package/dist/core/worldmesh.d.ts.map +1 -0
  29. package/dist/core/worldmesh.js +283 -0
  30. package/dist/core/worldmesh.js.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +18 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/movement/collision.d.ts +39 -0
  36. package/dist/movement/collision.d.ts.map +1 -0
  37. package/dist/movement/collision.js +93 -0
  38. package/dist/movement/collision.js.map +1 -0
  39. package/dist/movement/controller.d.ts +53 -0
  40. package/dist/movement/controller.d.ts.map +1 -0
  41. package/dist/movement/controller.js +200 -0
  42. package/dist/movement/controller.js.map +1 -0
  43. package/dist/net/adapter.d.ts +22 -0
  44. package/dist/net/adapter.d.ts.map +1 -0
  45. package/dist/net/adapter.js +8 -0
  46. package/dist/net/adapter.js.map +1 -0
  47. package/dist/player/avatar.d.ts +29 -0
  48. package/dist/player/avatar.d.ts.map +1 -0
  49. package/dist/player/avatar.js +420 -0
  50. package/dist/player/avatar.js.map +1 -0
  51. package/dist/player/player.d.ts +30 -0
  52. package/dist/player/player.d.ts.map +1 -0
  53. package/dist/player/player.js +76 -0
  54. package/dist/player/player.js.map +1 -0
  55. package/dist/portals/portals.d.ts +34 -0
  56. package/dist/portals/portals.d.ts.map +1 -0
  57. package/dist/portals/portals.js +132 -0
  58. package/dist/portals/portals.js.map +1 -0
  59. package/dist/types.d.ts +228 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/ui/overlay.d.ts +19 -0
  64. package/dist/ui/overlay.d.ts.map +1 -0
  65. package/dist/ui/overlay.js +370 -0
  66. package/dist/ui/overlay.js.map +1 -0
  67. package/package.json +63 -0
  68. package/src/abilities/abilities.ts +30 -0
  69. package/src/camera/cameraRig.ts +106 -0
  70. package/src/controls/input.ts +252 -0
  71. package/src/controls/keymap.ts +23 -0
  72. package/src/controls/touch.ts +378 -0
  73. package/src/core/events.ts +31 -0
  74. package/src/core/worldmesh.ts +313 -0
  75. package/src/index.ts +46 -0
  76. package/src/movement/collision.ts +109 -0
  77. package/src/movement/controller.ts +248 -0
  78. package/src/net/adapter.ts +28 -0
  79. package/src/player/avatar.ts +495 -0
  80. package/src/player/player.ts +91 -0
  81. package/src/portals/portals.ts +173 -0
  82. package/src/types.ts +230 -0
  83. package/src/ui/overlay.ts +398 -0
@@ -0,0 +1,313 @@
1
+ import { Vector3 } from 'three';
2
+ import { resolveAbilities } from '../abilities/abilities.js';
3
+ import { CameraRig } from '../camera/cameraRig.js';
4
+ import { Input } from '../controls/input.js';
5
+ import { CollisionWorld } from '../movement/collision.js';
6
+ import { MovementController } from '../movement/controller.js';
7
+ import { expressionForDigit, isAvatarExpression } from '../player/avatar.js';
8
+ import { Player } from '../player/player.js';
9
+ import { PortalManager, buildTravelUrl, type ResolvedPortal } from '../portals/portals.js';
10
+ import type {
11
+ PlayerState,
12
+ Vec3Tuple,
13
+ ViewMode,
14
+ WorldMeshEvents,
15
+ WorldMeshHandle,
16
+ WorldMeshOptions,
17
+ } from '../types.js';
18
+ import { Overlay } from '../ui/overlay.js';
19
+ import { isTouchDevice } from '../controls/touch.js';
20
+ import { Emitter } from './events.js';
21
+
22
+ /** Never simulate more than this per substep, so a stall cannot tunnel the player. */
23
+ const MAX_STEP = 1 / 60;
24
+ /** Never simulate more than this per frame, so a backgrounded tab does not catch up violently. */
25
+ const MAX_FRAME = 0.1;
26
+
27
+ /**
28
+ * Turn an ordinary Three.js scene into a WorldMesh world.
29
+ *
30
+ * This is the whole compatibility surface: a world that can produce a scene,
31
+ * a camera and a renderer gets the shared controls, both camera modes, the
32
+ * shared overlay and portals from one call.
33
+ */
34
+ export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
35
+ const { scene, camera, renderer } = options;
36
+ const spawn = new Vector3(...(options.spawn ?? [0, 2, 0]));
37
+ const abilities = resolveAbilities(options.abilities);
38
+
39
+ const height = options.player?.height ?? 1.8;
40
+ const radius = options.player?.radius ?? 0.35;
41
+
42
+ const collision = new CollisionWorld(options.colliders ?? [], options.groundLevel ?? 0);
43
+ const events = new Emitter<WorldMeshEvents>();
44
+
45
+ const overlay = new Overlay({
46
+ ...options.ui,
47
+ onEnter: (touch) => input.requestPointerLock(touch),
48
+ onInteract: () => {
49
+ if (activePortal) {
50
+ activatePortal(activePortal);
51
+ } else {
52
+ input.triggerAction('interact');
53
+ }
54
+ },
55
+ });
56
+
57
+ const input = new Input({
58
+ element: renderer.domElement,
59
+ keymap: options.keymap,
60
+ onPointerLockChange: (locked) => {
61
+ overlay.setLocked(locked);
62
+ events.emit('pointer:lock', { locked });
63
+ },
64
+ });
65
+
66
+ const cameraRig = new CameraRig({ ...options.view, camera, collision });
67
+ const player = new Player({ ...options.player, height, radius });
68
+ if (player.root) scene.add(player.root);
69
+
70
+ const controller = new MovementController({
71
+ input,
72
+ collision,
73
+ abilities,
74
+ tuning: options.movement,
75
+ height,
76
+ radius,
77
+ spawn,
78
+ });
79
+
80
+ const portals = new PortalManager(scene, options.portals);
81
+
82
+ let running = false;
83
+ let frameHandle = 0;
84
+ let lastTime = 0;
85
+ let elapsed = 0;
86
+ let activePortal: ResolvedPortal | null = null;
87
+ let disposed = false;
88
+
89
+ const handle: WorldMeshHandle = {
90
+ scene,
91
+ camera,
92
+ renderer,
93
+ abilities,
94
+ avatar: player.root,
95
+
96
+ start,
97
+ stop,
98
+ update,
99
+ dispose,
100
+
101
+ getState,
102
+ setState,
103
+ teleport,
104
+ respawn: () => doRespawn('manual'),
105
+
106
+ setViewMode,
107
+ getViewMode: () => cameraRig.mode,
108
+
109
+ addPortal: (portal) => {
110
+ portals.add(portal);
111
+ },
112
+ travelTo,
113
+
114
+ on: (event, fn) => events.on(event, fn),
115
+ off: (event, fn) => events.off(event, fn),
116
+
117
+ refreshColliders: () => collision.refresh(),
118
+ };
119
+
120
+ applyViewVisibility();
121
+
122
+ if (options.autoResize !== false) window.addEventListener('resize', handleResize);
123
+ handleResize();
124
+
125
+ if (options.autoStart !== false) start();
126
+
127
+ options.network?.attach(handle);
128
+
129
+ return handle;
130
+
131
+ function start(): void {
132
+ if (running || disposed) return;
133
+ running = true;
134
+ lastTime = performance.now();
135
+ frameHandle = requestAnimationFrame(frame);
136
+ }
137
+
138
+ function stop(): void {
139
+ running = false;
140
+ if (frameHandle) cancelAnimationFrame(frameHandle);
141
+ frameHandle = 0;
142
+ }
143
+
144
+ function frame(now: number): void {
145
+ if (!running) return;
146
+ frameHandle = requestAnimationFrame(frame);
147
+ const dt = Math.min((now - lastTime) / 1000, MAX_FRAME);
148
+ lastTime = now;
149
+ update(dt);
150
+ options.onUpdate?.(dt, handle);
151
+ renderer.render(scene, camera);
152
+ }
153
+
154
+ /** Advance the simulation. Split from rendering so a fixed tick can drive it. */
155
+ function update(dt: number): void {
156
+ if (dt <= 0) return;
157
+ elapsed += dt;
158
+
159
+ const look = input.readLook();
160
+ if (input.locked) {
161
+ cameraRig.look(look.dx, look.dy);
162
+ cameraRig.zoom(look.wheel);
163
+ if (input.consume('toggleView')) setViewMode(cameraRig.mode === 'first' ? 'third' : 'first');
164
+ const digit = input.consumeDigit();
165
+ const expression = digit === null ? null : expressionForDigit(digit);
166
+ if (expression) player.setExpression(expression);
167
+ }
168
+
169
+ // Fixed substeps keep movement stable regardless of frame rate.
170
+ let remaining = dt;
171
+ while (remaining > 0) {
172
+ const step = Math.min(remaining, MAX_STEP);
173
+ controller.step(step, cameraRig.yaw, input.locked);
174
+ remaining -= step;
175
+ }
176
+
177
+ if (controller.position.y < controller.tuning.fallLimit) doRespawn('fell');
178
+
179
+ cameraRig.update(dt, controller.position, player.eyeHeight * (controller.height / height));
180
+ player.sync(controller.position, cameraRig.yaw, controller.height, {
181
+ dt,
182
+ speed: Math.hypot(controller.velocity.x, controller.velocity.z),
183
+ grounded: controller.onGround || controller.flying,
184
+ heading: Math.atan2(-controller.velocity.x, -controller.velocity.z),
185
+ }, cameraRig.mode === 'third');
186
+ portals.animate(elapsed);
187
+
188
+ updatePortals();
189
+ input.endFrame();
190
+
191
+ events.emit('update', { dt, state: getState() });
192
+ }
193
+
194
+ function updatePortals(): void {
195
+ const nearest = portals.findActive(controller.position);
196
+
197
+ if (nearest !== activePortal) {
198
+ if (activePortal) events.emit('portal:exit', { portal: activePortal });
199
+ if (nearest) events.emit('portal:enter', { portal: nearest });
200
+ activePortal = nearest;
201
+ }
202
+
203
+ if (!nearest) {
204
+ overlay.setPrompt(null);
205
+ if (input.locked && input.consume('interact')) {
206
+ events.emit('interact', { position: controller.position.toArray() as Vec3Tuple });
207
+ }
208
+ return;
209
+ }
210
+
211
+ if (nearest.mode === 'auto') {
212
+ overlay.setPrompt(`Entering ${nearest.label}…`);
213
+ activatePortal(nearest);
214
+ return;
215
+ }
216
+
217
+ const isTouch = isTouchDevice();
218
+ overlay.setPrompt(isTouch ? `Tap to enter ${nearest.label}` : `Press E to enter ${nearest.label}`);
219
+ if (input.locked && input.consume('interact')) activatePortal(nearest);
220
+ }
221
+
222
+ function activatePortal(portal: ResolvedPortal): void {
223
+ const url = buildTravelUrl(portal.url);
224
+ let cancelled = false;
225
+ events.emit('portal:activate', {
226
+ portal,
227
+ url,
228
+ preventDefault: () => {
229
+ cancelled = true;
230
+ },
231
+ });
232
+ if (cancelled) return;
233
+ input.exitPointerLock();
234
+ window.location.href = url;
235
+ }
236
+
237
+ function setViewMode(mode: ViewMode): void {
238
+ if (cameraRig.mode === mode) return;
239
+ cameraRig.setMode(mode);
240
+ applyViewVisibility();
241
+ events.emit('view:change', { mode });
242
+ }
243
+
244
+ /** In first person the avatar would fill the screen. */
245
+ function applyViewVisibility(): void {
246
+ player.setVisible(cameraRig.mode === 'third');
247
+ }
248
+
249
+ function getState(): PlayerState {
250
+ return {
251
+ position: controller.position.toArray() as Vec3Tuple,
252
+ velocity: controller.velocity.toArray() as Vec3Tuple,
253
+ yaw: cameraRig.yaw,
254
+ pitch: cameraRig.pitch,
255
+ facing: player.facing,
256
+ onGround: controller.onGround,
257
+ crouching: controller.crouching,
258
+ flying: controller.flying,
259
+ expression: player.expression,
260
+ };
261
+ }
262
+
263
+ function setState(state: Partial<PlayerState>): void {
264
+ if (state.position) controller.position.set(...state.position);
265
+ if (state.velocity) controller.velocity.set(...state.velocity);
266
+ if (typeof state.yaw === 'number') cameraRig.yaw = state.yaw;
267
+ if (typeof state.facing === 'number') player.facing = state.facing;
268
+ if (typeof state.pitch === 'number') cameraRig.pitch = state.pitch;
269
+ if (typeof state.flying === 'boolean') controller.flying = state.flying;
270
+ if (isAvatarExpression(state.expression)) player.setExpression(state.expression);
271
+ }
272
+
273
+ function teleport(position: Vec3Tuple, yaw?: number): void {
274
+ controller.reset(new Vector3(...position));
275
+ if (typeof yaw === 'number') {
276
+ cameraRig.yaw = yaw;
277
+ player.facing = yaw;
278
+ }
279
+ }
280
+
281
+ function doRespawn(reason: 'fell' | 'manual'): void {
282
+ controller.reset(spawn);
283
+ events.emit('respawn', { reason });
284
+ }
285
+
286
+ function travelTo(url: string): void {
287
+ input.exitPointerLock();
288
+ window.location.href = buildTravelUrl(url);
289
+ }
290
+
291
+ function handleResize(): void {
292
+ const width = window.innerWidth;
293
+ const viewHeight = window.innerHeight;
294
+ // Pixel ratio first: setSize derives the drawing buffer from it, and it
295
+ // must also update the canvas CSS size or the canvas overflows the window.
296
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
297
+ renderer.setSize(width, viewHeight);
298
+ cameraRig.resize(width, viewHeight);
299
+ }
300
+
301
+ function dispose(): void {
302
+ if (disposed) return;
303
+ disposed = true;
304
+ stop();
305
+ window.removeEventListener('resize', handleResize);
306
+ options.network?.detach?.();
307
+ input.dispose();
308
+ overlay.dispose();
309
+ portals.dispose();
310
+ player.dispose();
311
+ events.clear();
312
+ }
313
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ export { createWorldMesh } from './core/worldmesh.js';
2
+ export { Emitter } from './core/events.js';
3
+ export { Input } from './controls/input.js';
4
+ export { TouchControls, isTouchDevice } from './controls/touch.js';
5
+ export { DEFAULT_KEYMAP, resolveKeymap } from './controls/keymap.js';
6
+ export { DEFAULT_ABILITIES, resolveAbilities } from './abilities/abilities.js';
7
+ export { CollisionWorld } from './movement/collision.js';
8
+ export { DEFAULT_TUNING, MovementController } from './movement/controller.js';
9
+ export { CameraRig } from './camera/cameraRig.js';
10
+ export { Player } from './player/player.js';
11
+ export {
12
+ AVATAR_EXPRESSIONS,
13
+ animateDefaultAvatar,
14
+ createDefaultAvatar,
15
+ expressionForDigit,
16
+ getAvatarExpression,
17
+ isAvatarExpression,
18
+ setAvatarExpression,
19
+ } from './player/avatar.js';
20
+ export type { AvatarExpression, AvatarMotion } from './player/avatar.js';
21
+ export { PortalManager, buildTravelUrl, getReferringWorld } from './portals/portals.js';
22
+ export { Overlay } from './ui/overlay.js';
23
+ export { deserializePlayerState, serializePlayerState } from './net/adapter.js';
24
+
25
+ export const WORLDMESH_VERSION = '0.1.0';
26
+ /** Bumped when the world-facing contract changes in an incompatible way. */
27
+ export const WORLDMESH_PROTOCOL = 1;
28
+
29
+ export type {
30
+ Abilities,
31
+ InputAction,
32
+ Keymap,
33
+ MovementTuning,
34
+ NetworkAdapter,
35
+ PlayerOptions,
36
+ PlayerState,
37
+ PortalMode,
38
+ PortalOptions,
39
+ UiOptions,
40
+ Vec3Tuple,
41
+ ViewMode,
42
+ ViewOptions,
43
+ WorldMeshEvents,
44
+ WorldMeshHandle,
45
+ WorldMeshOptions,
46
+ } from './types.js';
@@ -0,0 +1,109 @@
1
+ import { Object3D, Raycaster, Vector3 } from 'three';
2
+
3
+ const DOWN = new Vector3(0, -1, 0);
4
+
5
+ export interface GroundSample {
6
+ /**
7
+ * Surface height under the player. `-Infinity` when the world has colliders
8
+ * but none of them are below the player — that is a hole, and the player
9
+ * should keep falling rather than snap to an imaginary floor.
10
+ */
11
+ y: number;
12
+ hit: boolean;
13
+ }
14
+
15
+ /**
16
+ * Deliberately minimal collision: raycasts against a world-supplied list of
17
+ * meshes. No physics engine, no broadphase. It is enough to stand on things
18
+ * and to stop walking through walls, which is all the MVP needs to prove that
19
+ * one controller works across five very different worlds.
20
+ */
21
+ export class CollisionWorld {
22
+ private raycaster = new Raycaster();
23
+ private colliders: Object3D[] = [];
24
+ private source: Object3D[] | (() => Object3D[]);
25
+ /** Y used when a world supplies no colliders at all. */
26
+ groundLevel: number;
27
+
28
+ constructor(source: Object3D[] | (() => Object3D[]) = [], groundLevel = 0) {
29
+ this.source = source;
30
+ this.groundLevel = groundLevel;
31
+ this.refresh();
32
+ }
33
+
34
+ refresh(): void {
35
+ this.colliders = typeof this.source === 'function' ? this.source() : this.source;
36
+ }
37
+
38
+ get isEmpty(): boolean {
39
+ return this.colliders.length === 0;
40
+ }
41
+
42
+ /**
43
+ * Find the ground under `position`. `stepHeight` lets the player walk up
44
+ * small ledges without jumping; `maxDrop` limits how far down we look.
45
+ */
46
+ sampleGround(position: Vector3, stepHeight = 0.6, maxDrop = 4): GroundSample {
47
+ if (this.isEmpty) return { y: this.groundLevel, hit: false };
48
+
49
+ this.raycaster.set(new Vector3(position.x, position.y + stepHeight, position.z), DOWN);
50
+ this.raycaster.far = stepHeight + maxDrop;
51
+ const hits = this.raycaster.intersectObjects(this.colliders, true);
52
+ const hit = hits.find((candidate) => candidate.face !== null) ?? hits[0];
53
+ if (!hit) return { y: -Infinity, hit: false };
54
+ return { y: hit.point.y, hit: true };
55
+ }
56
+
57
+ /**
58
+ * Slide a horizontal movement `delta` along whatever it runs into.
59
+ * Two passes so that sliding into a corner does not tunnel through it.
60
+ */
61
+ resolveHorizontal(feet: Vector3, delta: Vector3, radius: number, height: number): Vector3 {
62
+ const result = delta.clone();
63
+ if (this.isEmpty || (result.x === 0 && result.z === 0)) return result;
64
+
65
+ // Probe up the body: a knee-only ray walks through railings, a chest-only
66
+ // ray walks through crates, and two rays miss anything between them.
67
+ const heights = [height * 0.15, height * 0.4, height * 0.65, height * 0.9];
68
+ const probe = new Vector3();
69
+ const direction = new Vector3();
70
+
71
+ for (let pass = 0; pass < 2; pass++) {
72
+ const distance = Math.hypot(result.x, result.z);
73
+ if (distance < 1e-5) break;
74
+ direction.set(result.x, 0, result.z).divideScalar(distance);
75
+
76
+ let blocked = false;
77
+ for (const offset of heights) {
78
+ probe.set(feet.x, feet.y + offset, feet.z);
79
+ this.raycaster.set(probe, direction);
80
+ this.raycaster.far = distance + radius;
81
+ const hit = this.raycaster.intersectObjects(this.colliders, true)[0];
82
+ if (!hit || !hit.face) continue;
83
+
84
+ const normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
85
+ normal.y = 0;
86
+ if (normal.lengthSq() < 1e-6) continue;
87
+ normal.normalize();
88
+
89
+ const into = result.dot(normal);
90
+ if (into < 0) {
91
+ result.addScaledVector(normal, -into);
92
+ blocked = true;
93
+ }
94
+ }
95
+ if (!blocked) break;
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ /** Distance from `origin` along `direction` before hitting something. */
102
+ castDistance(origin: Vector3, direction: Vector3, far: number): number {
103
+ if (this.isEmpty) return far;
104
+ this.raycaster.set(origin, direction);
105
+ this.raycaster.far = far;
106
+ const hit = this.raycaster.intersectObjects(this.colliders, true)[0];
107
+ return hit ? hit.distance : far;
108
+ }
109
+ }