Needle Engine

Changes between version 3.17.0 and 3.18.0
Files changed (8) hide show
  1. src/engine-components/api.ts +3 -2
  2. src/engine/debug/debug_console.ts +8 -6
  3. src/engine/engine_context.ts +5 -1
  4. src/engine/engine_element_attributes.ts +7 -5
  5. src/engine/engine_element_loading.ts +8 -4
  6. src/engine/engine_element.ts +23 -9
  7. src/engine-components/SceneSwitcher.ts +86 -9
  8. src/engine-components/SpriteRenderer.ts +5 -0
src/engine-components/api.ts CHANGED
@@ -13,7 +13,8 @@
13
13
  export * from "./webxr/index.js"
14
14
 
15
15
  export { ClearFlags } from "./Camera.js"
16
+ export { type ISceneEventListener } from "./SceneSwitcher.js";
16
17
 
18
+ import "./CameraUtils.js"
19
+ import "./AnimationUtils.js"
17
20
 
18
- import "./CameraUtils.js"
19
- import "./AnimationUtils.js"
src/engine/debug/debug_console.ts CHANGED
@@ -17,15 +17,16 @@
17
17
  }
18
18
 
19
19
  if (!suppressConsole && (showConsole || isLocalNetwork())) {
20
- if (isLocalNetwork())
21
- console.log("Add the ?console query parameter to the url to show the debug console (on mobile it will automatically open for local development when your get errors)");
20
+ if (isLocalNetwork()) {
21
+ const currentUrl = new URL(window.location.href);
22
+ currentUrl.searchParams.set("console", "1");
23
+ console.log("🌵 Tip: You can add the \"?console\" query parameter to the url to show the debug console (on mobile it will automatically open in the bottom right corner when your get errors during development)", "\nOpen this page console: " + currentUrl.toString());
24
+ }
22
25
  const isMobile = isMobileDevice();
23
- if (isMobile)
24
- {
26
+ if (isMobile) {
25
27
  beginWatchingLogs();
26
28
  createConsole(true);
27
- if (isMobile)
28
- {
29
+ if (isMobile) {
29
30
  const engineElement = document.querySelector("needle-engine");
30
31
  // setTimeout(() => {
31
32
  // const el = getConsoleElement();
@@ -192,6 +193,7 @@
192
193
  consoleHtmlElement?.prepend(styles);
193
194
  if (startHidden === true)
194
195
  hideDebugConsole();
196
+ console.log("🌵 Debug console has loaded");
195
197
  }
196
198
 
197
199
  };
src/engine/engine_context.ts CHANGED
@@ -796,7 +796,11 @@
796
796
 
797
797
  this._dispatchReadyAfterFrame = true;
798
798
  const res = ContextRegistry.dispatchCallback(ContextEvent.ContextCreated, this, { files: loadedFiles });
799
- if (res) await res;
799
+ if (res) {
800
+ if("internalSetLoadingMessage" in this.domElement && typeof this.domElement.internalSetLoadingMessage === "function")
801
+ this.domElement?.internalSetLoadingMessage("finish loading");
802
+ await res;
803
+ }
800
804
 
801
805
  this._isCreating = false;
802
806
  if (!this.isManagedExternally)
src/engine/engine_element_attributes.ts CHANGED
@@ -32,15 +32,17 @@
32
32
 
33
33
  type LoadingAttributes = {
34
34
  "loading-style"?: "light" | "dark",
35
- /** requires Pro license */
35
+ /** Pro feature */
36
+ "hide-loading-overlay"?: boolean,
37
+ /** Pro feature */
36
38
  "loading-background-color"?: string,
37
- /** requires Pro license */
39
+ /** Pro feature */
38
40
  "loading-logo-src"?: string,
39
- /** requires Pro license */
41
+ /** Pro feature */
40
42
  "primary-color"?: string,
41
- /** requires Pro license */
43
+ /** Pro feature */
42
44
  "secondary-color"?: string,
43
- /** requires Pro license */
45
+ /** Pro feature */
44
46
  "loading-text-color"?: string,
45
47
  }
46
48
 
src/engine/engine_element_loading.ts CHANGED
@@ -184,7 +184,7 @@
184
184
  else
185
185
  loadingStyle = "light";
186
186
  }
187
-
187
+
188
188
  const hasLicense = hasProLicense();
189
189
  if (!existing) {
190
190
  this._loadingElement.style.position = "absolute";
@@ -244,18 +244,22 @@
244
244
  logo.style.width = `${logoSize}px`;
245
245
  logo.style.height = `${logoSize}px`;
246
246
  logo.style.marginBottom = "20px";
247
- logo.addEventListener("click", () => window.open("https://needle.tools", "_blank"));
248
- logo.style.cursor = "pointer";
249
247
  logo.style.userSelect = "none";
250
- logo.style.pointerEvents = "all";
251
248
  logo.style.objectFit = "contain";
252
249
  logo.src = logoSVG;
250
+ let isUsingCustomLogo = false;
253
251
  if (hasLicense && this._element) {
254
252
  const customLogo = this._element.getAttribute("loading-logo-src");
255
253
  if (customLogo) {
254
+ isUsingCustomLogo = true;
256
255
  logo.src = customLogo;
257
256
  }
258
257
  }
258
+ if (!isUsingCustomLogo) {
259
+ logo.style.cursor = "pointer";
260
+ logo.style.pointerEvents = "all";
261
+ logo.addEventListener("click", () => window.open("https://needle.tools", "_blank"));
262
+ }
259
263
  this._loadingElement.appendChild(logo);
260
264
  this._loadingElement.appendChild(loadingBarContainer);
261
265
 
src/engine/engine_element.ts CHANGED
@@ -7,9 +7,7 @@
7
7
  import { getLoader, registerLoader } from "../engine/engine_gltf.js";
8
8
  import { NeedleGltfLoader } from "./engine_scenetools.js";
9
9
  import { INeedleEngineComponent, LoadedGLTF } from "./engine_types.js";
10
- import { isLocalNetwork } from "./engine_networking_utils.js";
11
10
  import { isDevEnvironment, showBalloonWarning } from "./debug/index.js";
12
- import { destroy } from "./engine_gameobject.js";
13
11
  import { hasCommercialLicense } from "./engine_license.js";
14
12
  import { VERSION } from "./engine_constants.js";
15
13
 
@@ -40,7 +38,7 @@
40
38
 
41
39
  // https://developers.google.com/web/fundamentals/web-components/customelements
42
40
  /** <needle-engine> web component. See @type {NeedleEngineAttributes} attributes for supported attributes
43
- * @type {import ("./engine_element_attributes").NeedleEngineAttributes}
41
+ * @type {import ("./engine_element_attributes.js").NeedleEngineAttributes}
44
42
  */
45
43
  export class NeedleEngineHTMLElement extends HTMLElement implements INeedleEngineComponent {
46
44
 
@@ -286,6 +284,13 @@
286
284
  },
287
285
  cancelable: true
288
286
  }));
287
+ if (allowOverridingDefaultLoading) {
288
+ // Handle the <needle-engine hide-loading-overlay> attribute
289
+ const hideOverlay = this.getAttribute("hide-loading-overlay");
290
+ if (hideOverlay !== null && hideOverlay !== undefined && hideOverlay !== "0") {
291
+ useDefaultLoading = false;
292
+ }
293
+ }
289
294
  // for local development we allow overriding the loading screen - but we notify the user that it won't work in a deployed environment
290
295
  if (useDefaultLoading === false && !allowOverridingDefaultLoading) {
291
296
  if (!isDevEnvironment())
@@ -372,6 +377,10 @@
372
377
  private onReady = () => this._loadingView?.onLoadingFinished();
373
378
  private onError = () => this._loadingView?.setMessage("Loading failed!");
374
379
 
380
+ private internalSetLoadingMessage(str: string) {
381
+ this._loadingView?.setMessage(str);
382
+ }
383
+
375
384
  private getSourceFiles(): Array<string> {
376
385
  const src: string | null | string[] = this.getAttribute("src");
377
386
  if (!src) return [];
@@ -426,13 +435,18 @@
426
435
  this.removeEventListener(eventName, prev as any);
427
436
  }
428
437
  if (typeof code === "string" && code.length > 0) {
429
- // indirect eval https://esbuild.github.io/content-types/#direct-eval
430
- const fn = (0, eval)(code);
431
- // const fn = new Function(newValue);
432
- if (typeof fn === "function") {
433
- this._previouslyRegisteredMap.set(eventName, fn);
434
- this.addEventListener(eventName, evt => fn?.call(globalThis, this._context, evt));
438
+ try {
439
+ // indirect eval https://esbuild.github.io/content-types/#direct-eval
440
+ const fn = (0, eval)(code);
441
+ // const fn = new Function(newValue);
442
+ if (typeof fn === "function") {
443
+ this._previouslyRegisteredMap.set(eventName, fn);
444
+ this.addEventListener(eventName, evt => fn?.call(globalThis, this._context, evt));
445
+ }
435
446
  }
447
+ catch (err) {
448
+ console.error("Error registering event " + eventName + "=\"" + code + "\" failed with the following error:\n", err);
449
+ }
436
450
  }
437
451
  }
438
452
 
src/engine-components/SceneSwitcher.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { AssetReference } from "../engine/engine_addressables.js";
2
2
  import { InputEvents } from "../engine/engine_input.js";
3
3
  import { isLocalNetwork } from "../engine/engine_networking_utils.js";
4
- import { getParam, setParamWithoutReload } from "../engine/engine_utils.js";
4
+ import { delay, getParam, setParamWithoutReload } from "../engine/engine_utils.js";
5
5
  import { serializable } from "../engine/engine_serialization.js";
6
6
  import { Behaviour, GameObject } from "./Component.js";
7
7
  import { registerObservableAttribute } from "../engine/engine_element_extras.js";
8
+ import { Object3D } from "three";
8
9
 
9
10
  const debug = getParam("debugsceneswitcher");
10
11
 
@@ -29,11 +30,22 @@
29
30
  index: number;
30
31
  }
31
32
 
33
+ /** The ISceneEventListener is called by the SceneSwitcher when a scene is loaded or unloaded. It must be added to the root object of your scene */
34
+ export interface ISceneEventListener {
35
+ /** Called when the scene is loaded and added */
36
+ sceneOpened?(): Promise<void>
37
+ /** Called before the scene is being removed (due to another scene being loaded) */
38
+ sceneClosing?(): Promise<void>
39
+ }
40
+
32
41
  export class SceneSwitcher extends Behaviour {
33
42
 
34
43
  @serializable(AssetReference)
35
44
  scenes!: AssetReference[];
36
45
 
46
+ @serializable(AssetReference)
47
+ loadingScene!: AssetReference;
48
+
37
49
  /** the url parameter that is set/used to store the currently loaded scene in, set to "" to disable */
38
50
  @serializable()
39
51
  queryParameterName: string = "scene";
@@ -86,7 +98,7 @@
86
98
  private _preloadScheduler?: PreLoadScheduler;
87
99
 
88
100
  awake(): void {
89
- if(debug) console.log("SceneSwitcher", this);
101
+ if (debug) console.log("SceneSwitcher", this);
90
102
  }
91
103
 
92
104
  async start() {
@@ -135,6 +147,9 @@
135
147
  this._preloadScheduler.maxLoadBehind = this.preloadPrevious;
136
148
  this._preloadScheduler.maxConcurrent = this.preloadConcurrent;
137
149
  this._preloadScheduler.begin();
150
+
151
+ // Begin loading the loading scene
152
+ // if (this.loadingScene) this.loadingScene.loadAssetAsync();
138
153
  }
139
154
 
140
155
  onDisable(): void {
@@ -157,7 +172,7 @@
157
172
  const state = _state?.state;
158
173
  if (state && state.startsWith(this.guid)) {
159
174
  const value = state.substr(this.guid.length + 2);
160
- if(debug) console.log("PopState", value);
175
+ if (debug) console.log("PopState", value);
161
176
  await this.trySelectSceneFromValue(value);
162
177
  }
163
178
  }
@@ -170,7 +185,7 @@
170
185
  private normalizedSwipeThresholdX = 0.1;
171
186
  private _didSwipe: boolean = false;
172
187
  private onPointerMove = (e: any) => {
173
- if(!this.useSwipe) return;
188
+ if (!this.useSwipe) return;
174
189
  if (!this._didSwipe && e.button === 0 && e.pointerType === "touch" && this.context.input.getPointerPressedCount() === 1) {
175
190
  const delta = this.context.input.getPointerPositionDelta(e.button);
176
191
  if (delta) {
@@ -294,7 +309,12 @@
294
309
  }
295
310
  async __internalSwitchScene(scene: AssetReference): Promise<boolean> {
296
311
  if (this._currentScene) {
297
- if(debug) console.log("UNLOAD", scene.uri)
312
+ if (debug) console.log("UNLOAD", scene.uri)
313
+ const sceneListener = this.tryGetSceneEventListener(this._currentScene.asset as any as Object3D);
314
+ if (sceneListener?.sceneClosing) {
315
+ const res = sceneListener.sceneClosing();
316
+ if (res instanceof Promise) await res;
317
+ }
298
318
  this._currentScene.unload();
299
319
  }
300
320
  this._currentScene = undefined;
@@ -303,7 +323,9 @@
303
323
  try {
304
324
  const loadStartEvt = new CustomEvent<LoadSceneEvent>("loadscene-start", { detail: { scene: scene, switcher: this, index: index } })
305
325
  this.dispatchEvent(loadStartEvt);
306
- await scene.loadAssetAsync();
326
+ await this.onStartLoading();
327
+ await scene.loadAssetAsync().catch(console.error);
328
+ await this.onEndLoading();
307
329
  const finishedEvt = new CustomEvent<LoadSceneEvent>("loadscene-finished", { detail: { scene: scene, switcher: this, index: index } });
308
330
  this.dispatchEvent(finishedEvt);
309
331
  if (finishedEvt.defaultPrevented) {
@@ -315,11 +337,11 @@
315
337
  return false;
316
338
  }
317
339
  if (this._currentIndex === index) {
318
- if(debug) console.log("ADD", scene.uri)
340
+ if (debug) console.log("ADD", scene.uri)
319
341
  this._currentScene = scene;
320
342
  GameObject.add(scene.asset, this.gameObject);
321
343
  if (this.useSceneLighting)
322
- this.context.sceneLighting.enable(scene)
344
+ this.context.sceneLighting.enable(scene);
323
345
  if (this.useHistory && index >= 0) {
324
346
  // take the index as the query parameter value
325
347
  let queryParameterValue = index.toString();
@@ -338,6 +360,12 @@
338
360
  history.pushState(stateName, "unused", location.href);
339
361
  }
340
362
  }
363
+ // Call SceneListener opened callback (if a SceneListener is in the scene)
364
+ const sceneListener = this.tryGetSceneEventListener(scene.asset as any as Object3D);
365
+ if (sceneListener?.sceneOpened) {
366
+ const res = sceneListener.sceneOpened();
367
+ if (res instanceof Promise) await res;
368
+ }
341
369
  return true;
342
370
  }
343
371
  }
@@ -350,7 +378,7 @@
350
378
  preload(index: number) {
351
379
  if (index >= 0 && index < this.scenes.length) {
352
380
  const scene = this.scenes[index];
353
- if(scene instanceof AssetReference)
381
+ if (scene instanceof AssetReference)
354
382
  return scene.preload();
355
383
  }
356
384
  return couldNotLoadScenePromise;
@@ -378,6 +406,7 @@
378
406
  const lowerCaseValue = value.toLowerCase();
379
407
  for (let i = 0; i < this.scenes.length; i++) {
380
408
  const scene = this.scenes[i];
409
+ if (!scene) continue;
381
410
  if (sceneUriToName(scene.uri).toLowerCase().includes(lowerCaseValue)) {
382
411
  return this.select(i);;
383
412
  }
@@ -394,6 +423,54 @@
394
423
 
395
424
  return couldNotLoadScenePromise;
396
425
  }
426
+
427
+ private _lastLoadingScene: AssetReference | undefined = undefined;
428
+ private _loadingScenePromise: Promise<boolean> | undefined = undefined;
429
+ private _isCurrentlyLoading: boolean = false;
430
+ private async onStartLoading() {
431
+ this._isCurrentlyLoading = true;
432
+ if (this.loadingScene) {
433
+ // save the last loading scene reference so that it can be changed at runtime
434
+ // since we cache the loading promise here
435
+ if(this._lastLoadingScene !== this.loadingScene) {
436
+ this._loadingScenePromise = undefined;
437
+ }
438
+ this._lastLoadingScene = this.loadingScene;
439
+ if (!this._loadingScenePromise) {
440
+ this._loadingScenePromise = this.loadingScene?.loadAssetAsync();
441
+ }
442
+ await this._loadingScenePromise;
443
+ if (this._isCurrentlyLoading && this.loadingScene?.asset) {
444
+ if (debug) console.log("Add loading scene", this.loadingScene.uri, this.loadingScene.asset)
445
+ const obj = this.loadingScene.asset as any as Object3D;
446
+ GameObject.add(obj, this.gameObject);
447
+ const sceneListener = this.tryGetSceneEventListener(obj);
448
+ if (sceneListener?.sceneOpened) {
449
+ const res = sceneListener.sceneOpened();
450
+ if (res instanceof Promise) await res;
451
+ }
452
+ }
453
+ }
454
+ }
455
+ private async onEndLoading() {
456
+ this._isCurrentlyLoading = false;
457
+ if (this.loadingScene?.asset) {
458
+ if (debug) console.log("Remove loading scene", this.loadingScene.uri);
459
+ const obj = this.loadingScene.asset as any as Object3D;
460
+ // try to find an ISceneEventListener component
461
+ const sceneListener = this.tryGetSceneEventListener(obj);
462
+ if (typeof sceneListener?.sceneClosing === "function") {
463
+ const res = sceneListener.sceneClosing();
464
+ if (res instanceof Promise) await res;
465
+ }
466
+ GameObject.remove(obj);
467
+ }
468
+ }
469
+
470
+ private tryGetSceneEventListener(obj: Object3D): ISceneEventListener | null {
471
+ const sceneListener = GameObject.foreachComponent(obj, c => (c as any as ISceneEventListener).sceneClosing ? c : null) as ISceneEventListener | null;
472
+ return sceneListener;
473
+ }
397
474
  }
398
475
 
399
476
 
src/engine-components/SpriteRenderer.ts CHANGED
@@ -187,6 +187,11 @@
187
187
  if (!this.__didAwake) return;
188
188
  if (!this.sprite?.spriteSheet?.sprites) return;
189
189
  const sprite = this.sprite.spriteSheet.sprites[this.spriteIndex];
190
+ if (!sprite) {
191
+ if (debug)
192
+ console.warn("Sprite not found", this.spriteIndex, this.sprite.spriteSheet.sprites);
193
+ return;
194
+ }
190
195
  if (!this._currentSprite) {
191
196
  const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
192
197
  if (!mat) return;