entangled-worlds-orbiters-shared 1.2.0 → 1.3.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.
@@ -20,15 +20,15 @@ export interface PeerIntent {
20
20
  }
21
21
  /** Messages exchanged over the relay seam. */
22
22
  export type SyncMessage = {
23
- kind: 'link:hello';
23
+ kind: 'beat:hello';
24
24
  id: string;
25
25
  } | {
26
- kind: 'link:timeline';
26
+ kind: 'beat:timeline';
27
27
  timeline: BeatTimelineState;
28
28
  ts: TimelineStamp;
29
29
  id: string;
30
30
  } | {
31
- kind: 'link:transport';
31
+ kind: 'beat:transport';
32
32
  id: string;
33
33
  playing: boolean;
34
34
  atSessionMs: number;
@@ -91,7 +91,7 @@ export class BeatTimeline {
91
91
  /** Broadcast presence (peers reply with their timeline). Safe to call periodically as a heartbeat. */
92
92
  announce() {
93
93
  if (this.enabled)
94
- this.relay.send(this.id, { kind: 'link:hello', id: this.id });
94
+ this.relay.send(this.id, { kind: 'beat:hello', id: this.id });
95
95
  }
96
96
  /** Toggle participation. Off = solo (no broadcast, no adoption). On = re-announce + re-adopt. */
97
97
  setEnabled(on) {
@@ -135,7 +135,7 @@ export class BeatTimeline {
135
135
  this.timeline = { ...timeline };
136
136
  this._ts = { time: this.sessionNowMs(), id: this.id };
137
137
  if (this.enabled)
138
- this.relay.send(this.id, { kind: 'link:timeline', timeline: this.timeline, ts: this._ts, id: this.id });
138
+ this.relay.send(this.id, { kind: 'beat:timeline', timeline: this.timeline, ts: this._ts, id: this.id });
139
139
  if (this.onChange)
140
140
  this.onChange();
141
141
  }
@@ -177,7 +177,7 @@ export class BeatTimeline {
177
177
  // ---- start/stop intent (opt-in; never adopted on join) ------------------------------
178
178
  setLocalPlaying(playing) {
179
179
  this.localPlaying = playing;
180
- this.relay.send(this.id, { kind: 'link:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
180
+ this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
181
181
  if (this.onChange)
182
182
  this.onChange();
183
183
  }
@@ -186,14 +186,14 @@ export class BeatTimeline {
186
186
  if (!this.enabled)
187
187
  return; // solo: ignore everything from the network
188
188
  switch (msg.kind) {
189
- case 'link:hello':
189
+ case 'beat:hello':
190
190
  this.peers.add(msg.id);
191
191
  // reply with our current timeline (tagged with our id) so the joiner adopts tempo/phase + learns us
192
- this.relay.send(this.id, { kind: 'link:timeline', timeline: this.timeline, ts: this._ts, id: this.id }, msg.id);
192
+ this.relay.send(this.id, { kind: 'beat:timeline', timeline: this.timeline, ts: this._ts, id: this.id }, msg.id);
193
193
  if (this.onChange)
194
194
  this.onChange();
195
195
  break;
196
- case 'link:timeline':
196
+ case 'beat:timeline':
197
197
  if (msg.id)
198
198
  this.peers.add(msg.id);
199
199
  if (newerTs(msg.ts, this._ts)) {
@@ -203,7 +203,7 @@ export class BeatTimeline {
203
203
  if (this.onChange)
204
204
  this.onChange();
205
205
  break;
206
- case 'link:transport':
206
+ case 'beat:transport':
207
207
  // OBSERVE by default (Link v3: never auto-adopt). `onPeerTransport` lets the app opt into
208
208
  // following (the "Sync play" toggle) — it decides whether to launch/stop locally.
209
209
  this.peers.add(msg.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -13,10 +13,22 @@
13
13
  "./core": "./src/core/index.js",
14
14
  "./config": "./src/config/index.js",
15
15
  "./utils": "./src/utils/index.js",
16
- "./clock": { "types": "./dist/clock/index.d.ts", "import": "./dist/clock/index.js" },
17
- "./clock/musical": { "types": "./dist/clock/musical.d.ts", "import": "./dist/clock/musical.js" },
18
- "./clock/sync": { "types": "./dist/clock/sync.d.ts", "import": "./dist/clock/sync.js" },
19
- "./clock/curves": { "types": "./dist/clock/curves.d.ts", "import": "./dist/clock/curves.js" }
16
+ "./clock": {
17
+ "types": "./dist/clock/index.d.ts",
18
+ "import": "./dist/clock/index.js"
19
+ },
20
+ "./clock/musical": {
21
+ "types": "./dist/clock/musical.d.ts",
22
+ "import": "./dist/clock/musical.js"
23
+ },
24
+ "./clock/sync": {
25
+ "types": "./dist/clock/sync.d.ts",
26
+ "import": "./dist/clock/sync.js"
27
+ },
28
+ "./clock/curves": {
29
+ "types": "./dist/clock/curves.d.ts",
30
+ "import": "./dist/clock/curves.js"
31
+ }
20
32
  },
21
33
  "files": [
22
34
  "src/**/*",
package/src/clock/sync.ts CHANGED
@@ -38,9 +38,9 @@ export interface PeerIntent {
38
38
 
39
39
  /** Messages exchanged over the relay seam. */
40
40
  export type SyncMessage =
41
- | { kind: 'link:hello'; id: string }
42
- | { kind: 'link:timeline'; timeline: BeatTimelineState; ts: TimelineStamp; id: string }
43
- | { kind: 'link:transport'; id: string; playing: boolean; atSessionMs: number };
41
+ | { kind: 'beat:hello'; id: string }
42
+ | { kind: 'beat:timeline'; timeline: BeatTimelineState; ts: TimelineStamp; id: string }
43
+ | { kind: 'beat:transport'; id: string; playing: boolean; atSessionMs: number };
44
44
 
45
45
  /** A node the relay can register + deliver to. */
46
46
  export interface RelayNode {
@@ -183,7 +183,7 @@ export class BeatTimeline implements RelayNode {
183
183
  }
184
184
  /** Broadcast presence (peers reply with their timeline). Safe to call periodically as a heartbeat. */
185
185
  announce(): void {
186
- if (this.enabled) this.relay.send(this.id, { kind: 'link:hello', id: this.id });
186
+ if (this.enabled) this.relay.send(this.id, { kind: 'beat:hello', id: this.id });
187
187
  }
188
188
 
189
189
  /** Toggle participation. Off = solo (no broadcast, no adoption). On = re-announce + re-adopt. */
@@ -228,7 +228,7 @@ export class BeatTimeline implements RelayNode {
228
228
  this.timeline = { ...timeline };
229
229
  this._ts = { time: this.sessionNowMs(), id: this.id };
230
230
  if (this.enabled)
231
- this.relay.send(this.id, { kind: 'link:timeline', timeline: this.timeline, ts: this._ts, id: this.id });
231
+ this.relay.send(this.id, { kind: 'beat:timeline', timeline: this.timeline, ts: this._ts, id: this.id });
232
232
  if (this.onChange) this.onChange();
233
233
  }
234
234
  /** Propose a tempo. Keeps the beat value continuous at the moment of change (no jump). */
@@ -269,7 +269,7 @@ export class BeatTimeline implements RelayNode {
269
269
  // ---- start/stop intent (opt-in; never adopted on join) ------------------------------
270
270
  setLocalPlaying(playing: boolean): void {
271
271
  this.localPlaying = playing;
272
- this.relay.send(this.id, { kind: 'link:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
272
+ this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
273
273
  if (this.onChange) this.onChange();
274
274
  }
275
275
 
@@ -277,17 +277,17 @@ export class BeatTimeline implements RelayNode {
277
277
  receive(msg: SyncMessage): void {
278
278
  if (!this.enabled) return; // solo: ignore everything from the network
279
279
  switch (msg.kind) {
280
- case 'link:hello':
280
+ case 'beat:hello':
281
281
  this.peers.add(msg.id);
282
282
  // reply with our current timeline (tagged with our id) so the joiner adopts tempo/phase + learns us
283
283
  this.relay.send(
284
284
  this.id,
285
- { kind: 'link:timeline', timeline: this.timeline, ts: this._ts, id: this.id },
285
+ { kind: 'beat:timeline', timeline: this.timeline, ts: this._ts, id: this.id },
286
286
  msg.id,
287
287
  );
288
288
  if (this.onChange) this.onChange();
289
289
  break;
290
- case 'link:timeline':
290
+ case 'beat:timeline':
291
291
  if (msg.id) this.peers.add(msg.id);
292
292
  if (newerTs(msg.ts, this._ts)) {
293
293
  this.timeline = { ...msg.timeline };
@@ -295,7 +295,7 @@ export class BeatTimeline implements RelayNode {
295
295
  }
296
296
  if (this.onChange) this.onChange();
297
297
  break;
298
- case 'link:transport':
298
+ case 'beat:transport':
299
299
  // OBSERVE by default (Link v3: never auto-adopt). `onPeerTransport` lets the app opt into
300
300
  // following (the "Sync play" toggle) — it decides whether to launch/stop locally.
301
301
  this.peers.add(msg.id);
@@ -1,21 +1,38 @@
1
1
  import * as THREE from 'three';
2
2
 
3
+ // `hemiRef`/`ambientRef` track the MOST-RECENTLY ensured scene's lights, only so the legacy
4
+ // `updateDefaultLights()` (which takes no scene) keeps targeting the active scene. They are NOT a
5
+ // "lights already created" cache — see ensureDefaultLights.
3
6
  let hemiRef = null;
4
7
  let ambientRef = null;
5
8
 
9
+ /**
10
+ * Ensure a scene has the default hemisphere + ambient lights. Idempotent PER SCENE.
11
+ *
12
+ * ORB-128: a multi-orbiter realm renders N independent scenes through one renderer. A module-level
13
+ * singleton light is wrong twice over — it's only added to the FIRST scene, and a THREE light can
14
+ * live in just one scene (adding it to a second removes it from the first), so scenes 2..N rendered
15
+ * DARK ("only one entangled world"). So create + add the lights PER scene, keyed by name so a
16
+ * re-call on the same scene is a no-op. Single-orbiter (one scene) is unchanged.
17
+ */
6
18
  export function ensureDefaultLights(scene) {
7
- if (!hemiRef) {
8
- hemiRef = new THREE.HemisphereLight(0xffffff, 0xffffff, 1.0);
9
- hemiRef.position.set(1, 1, 1);
10
- hemiRef.name = 'ew_HemisphereLight';
11
- scene.add(hemiRef);
19
+ let hemi = scene.getObjectByName('ew_HemisphereLight');
20
+ if (!hemi) {
21
+ hemi = new THREE.HemisphereLight(0xffffff, 0xffffff, 1.0);
22
+ hemi.position.set(1, 1, 1);
23
+ hemi.name = 'ew_HemisphereLight';
24
+ scene.add(hemi);
12
25
  }
13
- if (!ambientRef) {
14
- ambientRef = new THREE.AmbientLight(0xffffff, 1.0);
15
- ambientRef.name = 'ew_AmbientLight';
16
- scene.add(ambientRef);
26
+ let ambient = scene.getObjectByName('ew_AmbientLight');
27
+ if (!ambient) {
28
+ ambient = new THREE.AmbientLight(0xffffff, 1.0);
29
+ ambient.name = 'ew_AmbientLight';
30
+ scene.add(ambient);
17
31
  }
18
- return { hemi: hemiRef, ambient: ambientRef };
32
+ // updateDefaultLights() (no scene arg) targets the most-recently ensured scene's lights.
33
+ hemiRef = hemi;
34
+ ambientRef = ambient;
35
+ return { hemi, ambient };
19
36
  }
20
37
 
21
38
  export function updateDefaultLights({ ambientIntensity, diffuseIntensity, lightColor, lightDirection } = {}) {
@@ -1,21 +1,12 @@
1
1
  import * as THREE from 'three';
2
- import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
2
+ import { getKTX2LoaderForRenderer } from './ktx2.js';
3
3
 
4
4
  const textureLoader = new THREE.TextureLoader();
5
5
 
6
- // Shared singleton accessor (align with cubeMapLoader)
7
- function getSharedKTX2Loader() {
8
- if (window.__EW_KTX2_LOADER) return window.__EW_KTX2_LOADER;
9
- const renderer = window.__EW_RENDERER;
10
- const loader = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
11
- window.__EW_KTX2_LOADER = loader;
12
- return loader;
13
- }
14
-
15
- export function loadTexture(url, { signal } = {}) {
6
+ export function loadTexture(url, { signal, renderer } = {}) {
16
7
  return new Promise((resolve, reject) => {
17
8
  if (url && url.endsWith('.ktx2')) {
18
- const ktx2 = getSharedKTX2Loader();
9
+ const ktx2 = getKTX2LoaderForRenderer(renderer);
19
10
  const onAbort = () => reject(new Error('aborted'));
20
11
  if (signal) signal.addEventListener('abort', onAbort, { once: true });
21
12
  ktx2.load(
@@ -1,19 +1,9 @@
1
1
  import * as THREE from 'three';
2
- import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
3
2
  import { getPublicBase } from '../config/env.js';
3
+ import { getKTX2LoaderForRenderer } from './ktx2.js';
4
4
 
5
5
  // Base URL is resolved at runtime via helper
6
6
 
7
- // Shared loader + caches
8
- const ktx2SupportCache = new Map(); // base -> true|false
9
-
10
- function getSharedKTX2Loader(renderer) {
11
- if (window.__EW_KTX2_LOADER) return window.__EW_KTX2_LOADER;
12
- const loader = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
13
- window.__EW_KTX2_LOADER = loader;
14
- return loader;
15
- }
16
-
17
7
  export function normalizeSkycubeName(raw) {
18
8
  if (!raw) return '001';
19
9
  const legacy = raw.match(/^(\d{1,2})_skycube(?:_low)?$/);
@@ -68,7 +58,7 @@ function detectExtension(faceKeys) {
68
58
 
69
59
  // Load KTX2 faces and create skybox mesh
70
60
  async function loadFacesKTX2(faceKeys, renderer) {
71
- const loader = getSharedKTX2Loader(renderer);
61
+ const loader = getKTX2LoaderForRenderer(renderer);
72
62
  const loaded = [];
73
63
 
74
64
  for (const face of FACE_ORDER) {
@@ -1,14 +1,7 @@
1
1
  import * as THREE from 'three';
2
2
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
- import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
4
3
  import { ensureStandardMaterial, applyFlagsToMaterial } from '../utils/materials.js';
5
-
6
- function getSharedKTX2Loader(renderer) {
7
- if (window.__EW_KTX2_LOADER) return window.__EW_KTX2_LOADER;
8
- const loader = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
9
- window.__EW_KTX2_LOADER = loader;
10
- return loader;
11
- }
4
+ import { getKTX2LoaderForRenderer } from './ktx2.js';
12
5
 
13
6
  export async function loadGLB(url, { retries = 3, signal, onProgress, applyFlags, withCredentials = false } = {}) {
14
7
  const renderer = window.__EW_RENDERER;
@@ -20,7 +13,7 @@ export async function loadGLB(url, { retries = 3, signal, onProgress, applyFlags
20
13
  const loader = new GLTFLoader()
21
14
  .setCrossOrigin(withCredentials ? 'use-credentials' : 'anonymous')
22
15
  .setWithCredentials(Boolean(withCredentials))
23
- .setKTX2Loader(getSharedKTX2Loader(renderer));
16
+ .setKTX2Loader(getKTX2LoaderForRenderer(renderer));
24
17
 
25
18
  const root = await new Promise((resolve, reject) => {
26
19
  loader.load(
@@ -0,0 +1,54 @@
1
+ import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
2
+
3
+ /**
4
+ * One KTX2Loader PER RENDERER.
5
+ *
6
+ * `KTX2Loader.detectSupport(renderer)` binds the loader's Basis transcoder worker pool to ONE WebGL
7
+ * context. A single shared loader therefore only decodes textures for the FIRST renderer it saw —
8
+ * every other view's planets/skyboxes silently fail and render untextured (dark). That breaks any
9
+ * multi-view page: N orbiters in one realm, or N Entangled World planets shown together.
10
+ *
11
+ * Keying the loader by renderer gives each view its own correctly-bound loader. Single-renderer apps
12
+ * (a single orbiter, today's Entangled World) get exactly ONE entry, so behaviour is byte-identical.
13
+ * (Was: a single `window.__EW_KTX2_LOADER` global shared by bodyTexture/glb/cubeMap loaders.)
14
+ */
15
+ const loadersByRenderer = new WeakMap();
16
+ let fallbackLoader = null;
17
+
18
+ function makeLoader(renderer) {
19
+ return new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
20
+ }
21
+
22
+ /**
23
+ * The KTX2Loader bound to `renderer`. Defaults to the current `window.__EW_RENDERER` so existing call
24
+ * sites need no change; pass the renderer explicitly when it is in scope (preferred).
25
+ * @param {import('three').WebGLRenderer} [renderer]
26
+ * @returns {KTX2Loader}
27
+ */
28
+ export function getKTX2LoaderForRenderer(
29
+ renderer = (typeof window !== 'undefined' ? window.__EW_RENDERER : null),
30
+ ) {
31
+ if (!renderer) {
32
+ if (!fallbackLoader) fallbackLoader = makeLoader(renderer);
33
+ return fallbackLoader;
34
+ }
35
+ let loader = loadersByRenderer.get(renderer);
36
+ if (!loader) {
37
+ loader = makeLoader(renderer);
38
+ loadersByRenderer.set(renderer, loader);
39
+ }
40
+ return loader;
41
+ }
42
+
43
+ /**
44
+ * Dispose and forget the KTX2Loader for a renderer — call on that renderer's / scene's teardown so the
45
+ * transcoder workers are terminated (avoids leaking worker threads + GPU memory across view churn).
46
+ * @param {import('three').WebGLRenderer} renderer
47
+ */
48
+ export function disposeKTX2LoaderForRenderer(renderer) {
49
+ const loader = renderer && loadersByRenderer.get(renderer);
50
+ if (loader) {
51
+ try { loader.dispose?.(); } catch { /* already gone */ }
52
+ loadersByRenderer.delete(renderer);
53
+ }
54
+ }
@@ -4,8 +4,12 @@ import { makeSphere } from './worldTexture.js';
4
4
  import { makeNormalSphere } from './worldNormal.js';
5
5
 
6
6
  export class WorldManager {
7
- constructor(scene, { onLoadStart, onLoadEnd } = {}) {
7
+ constructor(scene, { onLoadStart, onLoadEnd, renderer } = {}) {
8
8
  this.scene = scene;
9
+ // The renderer this world belongs to — threaded to the texture loaders so each view's textures
10
+ // bind to its OWN WebGL context (multi-view: N orbiters / N planets in one page). Falls back to
11
+ // window.__EW_RENDERER for callers that don't pass one, keeping single-renderer apps unchanged.
12
+ this.renderer = renderer ?? null;
9
13
  this.current = null;
10
14
  this.flags = {};
11
15
  this.onLoadStart = onLoadStart;
@@ -37,20 +41,22 @@ export class WorldManager {
37
41
  this.onLoadStart?.();
38
42
  try {
39
43
  let obj;
40
-
44
+ const renderer = this.renderer ?? (typeof window !== 'undefined' ? window.__EW_RENDERER : null);
45
+
41
46
  // Check for custom creators first
42
47
  if (this.customCreators.has(mode)) {
43
- obj = await this.customCreators.get(mode)({ flags: this.flags, ...args });
48
+ obj = await this.customCreators.get(mode)({ flags: this.flags, ...args, renderer });
44
49
  } else if (mode === 2) {
45
50
  obj = await makeGLB({
46
51
  modelUrl: args.modelUrl,
47
52
  flags: this.flags,
48
53
  withCredentials: Boolean(args.withCredentials),
54
+ renderer,
49
55
  });
50
56
  } else if (mode === 3) {
51
- obj = await makeNormalSphere({ flags: this.flags, ...args });
57
+ obj = await makeNormalSphere({ flags: this.flags, ...args, renderer });
52
58
  } else {
53
- obj = await makeSphere({ textureUrl: args.textureUrl, flags: this.flags, ...args });
59
+ obj = await makeSphere({ textureUrl: args.textureUrl, flags: this.flags, ...args, renderer });
54
60
  }
55
61
 
56
62
  if (this.token !== myToken) {
@@ -61,12 +67,12 @@ export class WorldManager {
61
67
  this.current = obj;
62
68
 
63
69
  // If the object exposes a frame updater, run a render-time loop
64
- if (obj && obj.userData && typeof obj.userData.update === 'function' && window.__EW_RENDERER) {
70
+ if (obj && obj.userData && typeof obj.userData.update === 'function' && renderer) {
65
71
  const updater = obj.userData.update;
66
72
  const tick = () => {
67
73
  if (this.current === obj) {
68
74
  try {
69
- updater(window.__EW_RENDERER, performance.now() * 0.001);
75
+ updater(renderer, performance.now() * 0.001);
70
76
  } catch (e) {
71
77
  console.warn('[WorldManager] update loop error:', e);
72
78
  }
@@ -52,6 +52,7 @@ function isLikelyEquirectangular(img) {
52
52
 
53
53
  export async function makeSphere({
54
54
  textureUrl,
55
+ renderer,
55
56
  flags,
56
57
  uOffset,
57
58
  textureOffsetDeg,
@@ -169,7 +170,7 @@ export async function makeSphere({
169
170
  const chosenTexture = textureUrl; // require explicit textureUrl — no implicit default
170
171
  if (chosenTexture) {
171
172
  try {
172
- const tex = await loadTexture(chosenTexture);
173
+ const tex = await loadTexture(chosenTexture, { renderer });
173
174
  tex.colorSpace = THREE.SRGBColorSpace;
174
175
  tex.anisotropy = 8;
175
176
  if (typeof flipY === 'boolean') tex.flipY = flipY;