@playcanvas/web-components 0.10.1 → 0.11.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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.ts +102 -51
  3. package/dist/asset.d.ts +8 -7
  4. package/dist/async-element.d.ts +21 -5
  5. package/dist/components/button-component.d.ts +3 -7
  6. package/dist/components/camera-component.d.ts +16 -20
  7. package/dist/components/collision-component.d.ts +3 -7
  8. package/dist/components/component.d.ts +22 -4
  9. package/dist/components/element-component.d.ts +4 -8
  10. package/dist/components/gsplat-component.d.ts +2 -7
  11. package/dist/components/layoutchild-component.d.ts +2 -7
  12. package/dist/components/layoutgroup-component.d.ts +3 -7
  13. package/dist/components/light-component.d.ts +3 -7
  14. package/dist/components/listener-component.d.ts +1 -6
  15. package/dist/components/particlesystem-component.d.ts +2 -7
  16. package/dist/components/render-component.d.ts +2 -7
  17. package/dist/components/rigidbody-component.d.ts +3 -7
  18. package/dist/components/screen-component.d.ts +28 -11
  19. package/dist/components/script-component.d.ts +8 -20
  20. package/dist/components/script.d.ts +2 -22
  21. package/dist/components/scrollbar-component.d.ts +2 -7
  22. package/dist/components/scrollview-component.d.ts +9 -11
  23. package/dist/components/sound-component.d.ts +2 -7
  24. package/dist/components/sound-slot.d.ts +8 -6
  25. package/dist/custom-elements.json +5191 -10695
  26. package/dist/entity.d.ts +16 -9
  27. package/dist/index.d.ts +37 -0
  28. package/dist/material.d.ts +12 -12
  29. package/dist/model.d.ts +21 -5
  30. package/dist/module.d.ts +0 -6
  31. package/dist/parse.d.ts +6 -3
  32. package/dist/pwc.cjs +799 -287
  33. package/dist/pwc.cjs.map +1 -1
  34. package/dist/pwc.js +799 -287
  35. package/dist/pwc.js.map +1 -1
  36. package/dist/pwc.min.js +1 -1
  37. package/dist/pwc.min.js.map +1 -1
  38. package/dist/pwc.min.mjs +1 -1
  39. package/dist/pwc.min.mjs.map +1 -1
  40. package/dist/pwc.mjs +800 -288
  41. package/dist/pwc.mjs.map +1 -1
  42. package/dist/scene.d.ts +4 -7
  43. package/dist/sky.d.ts +25 -16
  44. package/dist/vscode.html-custom-data.json +65 -45
  45. package/dist/web-types.json +585 -557
  46. package/package.json +8 -7
  47. package/src/app.ts +326 -144
  48. package/src/asset.ts +34 -28
  49. package/src/async-element.ts +34 -8
  50. package/src/components/button-component.ts +5 -9
  51. package/src/components/camera-component.ts +55 -36
  52. package/src/components/collision-component.ts +26 -15
  53. package/src/components/component.ts +58 -8
  54. package/src/components/element-component.ts +26 -30
  55. package/src/components/gsplat-component.ts +4 -9
  56. package/src/components/layoutchild-component.ts +4 -9
  57. package/src/components/layoutgroup-component.ts +14 -9
  58. package/src/components/light-component.ts +42 -12
  59. package/src/components/listener-component.ts +1 -7
  60. package/src/components/particlesystem-component.ts +7 -15
  61. package/src/components/render-component.ts +5 -10
  62. package/src/components/rigidbody-component.ts +23 -16
  63. package/src/components/screen-component.ts +46 -20
  64. package/src/components/script-component.ts +108 -46
  65. package/src/components/script.ts +38 -33
  66. package/src/components/scrollbar-component.ts +6 -16
  67. package/src/components/scrollview-component.ts +22 -15
  68. package/src/components/sound-component.ts +10 -15
  69. package/src/components/sound-slot.ts +30 -20
  70. package/src/entity.ts +75 -34
  71. package/src/index.ts +45 -1
  72. package/src/loading-bar.ts +8 -8
  73. package/src/material.ts +63 -37
  74. package/src/model.ts +69 -14
  75. package/src/module.ts +8 -7
  76. package/src/parse.ts +67 -20
  77. package/src/scene.ts +12 -9
  78. package/src/sky.ts +76 -34
package/src/app.ts CHANGED
@@ -1,11 +1,10 @@
1
+ import type { CameraComponent, GraphNode, GSplatComponent, Entity } from 'playcanvas';
1
2
  import {
2
3
  AppBase,
3
4
  AppOptions,
4
- CameraComponent,
5
5
  createGraphicsDevice,
6
6
  ElementInput,
7
7
  FILLMODE_FILL_WINDOW,
8
- GraphNode,
9
8
  Keyboard,
10
9
  Mouse,
11
10
  Picker,
@@ -62,17 +61,19 @@ import {
62
61
  SoundManager,
63
62
  Lightmapper,
64
63
  XrManager,
65
- MeshInstance,
66
- GSplatComponent
64
+ MeshInstance
67
65
  } from 'playcanvas';
68
66
 
69
- import { AssetElement } from './asset';
67
+ import type { AssetElement } from './asset';
70
68
  import { AsyncElement } from './async-element';
71
- import { EntityElement } from './entity';
69
+ import type { EntityElement } from './entity';
72
70
  import { LoadingBar } from './loading-bar';
73
- import { MaterialElement } from './material';
74
- import { ModuleElement } from './module';
75
- import { parseBool, parseEnum } from './parse';
71
+ import type { MaterialElement } from './material';
72
+ import type { ModuleElement } from './module';
73
+ import { parseBool, parseEnum, parseNumber } from './parse';
74
+
75
+ /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
76
+ const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'] as const;
76
77
 
77
78
  /**
78
79
  * The AppElement interface provides properties and methods for manipulating
@@ -97,21 +98,49 @@ class AppElement extends AsyncElement {
97
98
 
98
99
  private _antialias = true;
99
100
 
100
- private _depth = true;
101
+ private _depthBuffer = true;
101
102
 
102
- private _stencil = true;
103
+ private _stencilBuffer = true;
103
104
 
104
- private _highResolution = true;
105
+ private _maxPixelRatio = Infinity;
105
106
 
106
107
  private _loadingBar = true;
107
108
 
109
+ /**
110
+ * Set once the graphics options above have been handed to `createGraphicsDevice`, after which
111
+ * writing any of them changes nothing. Guards the warning in {@link _warnIfBooted}, and is
112
+ * cleared on disconnect so a re-connected element boots from its current attributes.
113
+ */
114
+ private _optionsLocked = false;
115
+
108
116
  private _bar: LoadingBar | null = null;
109
117
 
110
- private _hierarchyReady = false;
118
+ /**
119
+ * Whether the application has created its initial entity hierarchy. Read by EntityElement to
120
+ * decide whether a newly connected element must create its entity itself or leave it to the
121
+ * boot sweep.
122
+ * @internal
123
+ */
124
+ _hierarchyReady = false;
125
+
126
+ /**
127
+ * Incremented on every connect and disconnect. Boot captures the value on entry and abandons
128
+ * itself wherever it resumes from an await if the value has moved on — so a boot whose
129
+ * element was removed cannot complete against a torn-down element, and a boot whose element
130
+ * was removed and re-inserted (which starts a boot of its own) cannot race the newer one.
131
+ */
132
+ private _bootGeneration = 0;
133
+
134
+ /**
135
+ * The elements backing this application's entities, keyed by the entity itself. Registered
136
+ * by EntityElement at creation and removed when an entity is destroyed, this joins engine
137
+ * scene nodes back to their owning elements by identity - never by name.
138
+ */
139
+ private _entityElements = new Map<GraphNode, EntityElement>();
111
140
 
112
141
  private _picker: Picker | null = null;
113
142
 
114
- private _hasPointerListeners: { [key: string]: boolean } = {
143
+ private _hasPointerListeners: Record<string, boolean> = {
115
144
  pointerenter: false,
116
145
  pointerleave: false,
117
146
  pointerdown: false,
@@ -124,7 +153,7 @@ class AppElement extends AsyncElement {
124
153
  // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
125
154
  private _pickToken = 0;
126
155
 
127
- private _pointerHandlers: { [key: string]: EventListener | null } = {
156
+ private _pointerHandlers: Record<string, EventListener | null> = {
128
157
  pointermove: null,
129
158
  pointerdown: null,
130
159
  pointerup: null
@@ -165,9 +194,19 @@ class AppElement extends AsyncElement {
165
194
 
166
195
  // Bind methods to maintain 'this' context
167
196
  this._onWindowResize = this._onWindowResize.bind(this);
197
+
198
+ // Track pointer listeners being added to and removed from descendant entities.
199
+ // Registered once here rather than on every boot - the handlers no-op while there is no
200
+ // canvas, and a re-booted element must not stack a second set.
201
+ pointerEventTypes.forEach((type) => {
202
+ this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
203
+ this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
204
+ });
168
205
  }
169
206
 
170
207
  async connectedCallback() {
208
+ const generation = ++this._bootGeneration;
209
+
171
210
  // Created before the first await, so the bar is visible while modules and the graphics
172
211
  // device are created, and exists before any disconnect could need to clean it up
173
212
  if (this._loadingBar && !this._bar) {
@@ -178,29 +217,49 @@ class AppElement extends AsyncElement {
178
217
  const moduleElements = this.querySelectorAll<ModuleElement>(':scope > pc-module');
179
218
 
180
219
  // Wait for all modules to load
181
- await Promise.all(Array.from(moduleElements).map(module => module.getLoadPromise()));
220
+ await Promise.all(Array.from(moduleElements).map((module) => module._getLoadPromise()));
221
+
222
+ // The element may have been removed while the modules loaded. Nothing beyond the loading
223
+ // bar exists yet, and disconnectedCallback has already destroyed that.
224
+ if (generation !== this._bootGeneration) {
225
+ return;
226
+ }
182
227
 
183
228
  // Create and append the canvas to the element
184
229
  this._canvas = document.createElement('canvas');
185
230
  this.appendChild(this._canvas);
186
231
 
187
232
  // Configure device types based on backend selection
188
- const backendToDeviceTypes: { [key: string]: string[] } = {
233
+ const backendToDeviceTypes: Record<string, string[]> = {
189
234
  webgpu: ['webgpu', 'webgl2'], // fallback to webgl2 if webgpu not available
190
235
  webgl2: ['webgl2'],
191
236
  null: ['null']
192
237
  };
193
238
  const deviceTypes = backendToDeviceTypes[this._backend] || [];
194
239
 
240
+ this._optionsLocked = true;
241
+
195
242
  const device = await createGraphicsDevice(this._canvas, {
196
243
  // @ts-ignore - alpha needs to be documented
197
244
  alpha: this._alpha,
198
245
  antialias: this._antialias,
199
- depth: this._depth,
246
+ depth: this._depthBuffer,
200
247
  deviceTypes: deviceTypes,
201
- stencil: this._stencil
248
+ stencil: this._stencilBuffer
202
249
  });
203
- device.maxPixelRatio = this._highResolution ? window.devicePixelRatio : 1;
250
+
251
+ // The element may have been removed while the device was created. disconnectedCallback
252
+ // has already cleaned up the canvas; the device was created inside the await, so it is
253
+ // this boot's to release.
254
+ if (generation !== this._bootGeneration) {
255
+ device.destroy();
256
+ return;
257
+ }
258
+
259
+ // Assigned rather than resolved to a number here: the engine caps against the live
260
+ // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
261
+ // display when a window moves between monitors of differing density.
262
+ device.maxPixelRatio = this._maxPixelRatio;
204
263
 
205
264
  const createOptions = new AppOptions();
206
265
  createOptions.graphicsDevice = device;
@@ -278,31 +337,47 @@ class AppElement extends AsyncElement {
278
337
 
279
338
  // Get all pc-asset elements that are direct children of the pc-app element
280
339
  const assetElements = this.querySelectorAll<AssetElement>(':scope > pc-asset');
281
- Array.from(assetElements).forEach((assetElement) => {
282
- assetElement.createAsset();
340
+ for (const assetElement of Array.from(assetElements)) {
341
+ assetElement._createAsset();
283
342
  const asset = assetElement.asset;
284
343
  if (asset) {
285
344
  app.assets.add(asset);
345
+
346
+ // Adding a fileless asset (one built purely from data, such as a sprite)
347
+ // completes it synchronously, dispatching the element's load event - whose
348
+ // listeners may have removed this element. Stop before the next addition
349
+ // reaches the destroyed registry, and before orphan entities are created.
350
+ if (generation !== this._bootGeneration) {
351
+ return;
352
+ }
286
353
  }
287
- });
354
+ }
288
355
 
289
356
  // Get all pc-material elements that are direct children of the pc-app element
290
357
  const materialElements = this.querySelectorAll<MaterialElement>(':scope > pc-material');
291
358
  Array.from(materialElements).forEach((materialElement) => {
292
- materialElement.createMaterial();
359
+ materialElement._createMaterial();
293
360
  });
294
361
 
295
362
  // Create all entities
296
363
  const entityElements = this.querySelectorAll<EntityElement>('pc-entity');
297
364
  Array.from(entityElements).forEach((entityElement) => {
298
- entityElement.createEntity(app);
365
+ entityElement._createEntity(app);
299
366
  });
300
367
 
301
368
  // Build hierarchy
302
369
  entityElements.forEach((entityElement) => {
303
- entityElement.buildHierarchy(app);
370
+ entityElement._buildHierarchy(app);
304
371
  });
305
372
 
373
+ // Building the hierarchy dispatched each entity's ready event synchronously, and a
374
+ // listener may have removed the element. The sweep itself degrades safely - destroying
375
+ // the application nulls every element's entity, so the remaining builds no-op - but the
376
+ // teardown's reset must not be overwritten here.
377
+ if (generation !== this._bootGeneration) {
378
+ return;
379
+ }
380
+
306
381
  this._hierarchyReady = true;
307
382
 
308
383
  // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
@@ -322,8 +397,21 @@ class AppElement extends AsyncElement {
322
397
  this._bar?.progress(0, total);
323
398
  this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
324
399
 
400
+ // The progress dispatch above ran listeners synchronously, and one may have removed the
401
+ // element. The application is already destroyed - it must not be asked to preload.
402
+ if (generation !== this._bootGeneration) {
403
+ return;
404
+ }
405
+
325
406
  // Load assets before starting the application
326
407
  app.preload(() => {
408
+ // The element may have been removed while assets loaded. The application is already
409
+ // destroyed, so it must not be started — and readiness must not be signaled for a
410
+ // boot that no longer owns the element.
411
+ if (generation !== this._bootGeneration) {
412
+ return;
413
+ }
414
+
327
415
  // Scope the counter to this preload pass, so a later app.preload() call by user code
328
416
  // cannot push `loaded` past `total`
329
417
  app.off('preload:progress', onPreloadProgress);
@@ -344,17 +432,30 @@ class AppElement extends AsyncElement {
344
432
  }
345
433
 
346
434
  disconnectedCallback() {
435
+ // Invalidate any boot still in flight, so it abandons itself when it next resumes
436
+ // instead of completing against a torn-down element.
437
+ this._bootGeneration++;
438
+
439
+ this._optionsLocked = false;
347
440
  this._pickerDestroy();
348
441
 
349
- // Clean up the application
442
+ // Clean up the application. Destroying it destroys every entity, whose destroy hooks
443
+ // unregister them - clear() covers any entity the engine no longer reached.
350
444
  if (this._app) {
351
445
  this._app.destroy();
352
446
  this._app = null;
353
447
  }
448
+ this._entityElements.clear();
354
449
  this._loadProgress = 0;
355
450
  this._bar?.destroy();
356
451
  this._bar = null;
357
452
 
453
+ // Return the element to its pre-boot state, so re-inserting it boots afresh: descendants
454
+ // must neither see a hierarchy that no longer exists nor resume against a readiness that
455
+ // no longer holds.
456
+ this._hierarchyReady = false;
457
+ this._resetReady();
458
+
358
459
  // Remove event listeners
359
460
  window.removeEventListener('resize', this._onWindowResize);
360
461
 
@@ -365,13 +466,13 @@ class AppElement extends AsyncElement {
365
466
  }
366
467
  }
367
468
 
368
- _onWindowResize() {
469
+ private _onWindowResize() {
369
470
  if (this.app) {
370
471
  this.app.resizeCanvas();
371
472
  }
372
473
  }
373
474
 
374
- _pickerCreate() {
475
+ private _pickerCreate() {
375
476
  const { width, height } = this.app!.graphicsDevice;
376
477
  this._picker = new Picker(this.app!, width, height);
377
478
 
@@ -388,22 +489,20 @@ class AppElement extends AsyncElement {
388
489
  this._pointerHandlers.pointerdown = listener(this._onPointerDown);
389
490
  this._pointerHandlers.pointerup = listener(this._onPointerUp);
390
491
 
391
- // Listen for pointer listeners being added/removed
392
- ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
393
- this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
394
- this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
395
-
396
- // Attach canvas handlers for listeners registered before this point (e.g. handlers
397
- // created from onpointer* attributes when their elements were first upgraded)
398
- const anyListeners = Array.from(this.querySelectorAll<EntityElement>('pc-entity'))
399
- .some(entity => entity.hasListeners(type));
492
+ // Attach canvas handlers for listeners registered before this boot (e.g. handlers
493
+ // created from onpointer* attributes when their elements were first upgraded, or
494
+ // listeners carried over from before a re-boot)
495
+ pointerEventTypes.forEach((type) => {
496
+ const anyListeners = Array.from(this.querySelectorAll<EntityElement>('pc-entity')).some((entity) =>
497
+ entity._hasListeners(type)
498
+ );
400
499
  if (anyListeners) {
401
500
  this._onPointerListenerAdded(type);
402
501
  }
403
502
  });
404
503
  }
405
504
 
406
- _pickerDestroy() {
505
+ private _pickerDestroy() {
407
506
  if (this._canvas) {
408
507
  Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
409
508
  if (handler) {
@@ -413,6 +512,7 @@ class AppElement extends AsyncElement {
413
512
  }
414
513
 
415
514
  this._picker = null;
515
+ this._hoveredEntity = null;
416
516
  this._pointerHandlers = {
417
517
  pointermove: null,
418
518
  pointerdown: null,
@@ -427,8 +527,81 @@ class AppElement extends AsyncElement {
427
527
  };
428
528
  }
429
529
 
530
+ /**
531
+ * Registers the element that created an entity. Called by EntityElement when it creates its
532
+ * entity.
533
+ *
534
+ * @param entity - The entity.
535
+ * @param element - The element that created it.
536
+ * @internal
537
+ */
538
+ _registerEntityElement(entity: Entity, element: EntityElement) {
539
+ this._entityElements.set(entity, element);
540
+ }
541
+
542
+ /**
543
+ * Removes the registration for a destroyed entity. Called by EntityElement.
544
+ *
545
+ * @param entity - The entity.
546
+ * @internal
547
+ */
548
+ _unregisterEntityElement(entity: Entity) {
549
+ this._entityElements.delete(entity);
550
+ }
551
+
552
+ /**
553
+ * Returns the `<pc-entity>` element whose backing entity is `entity`, or `null` if the
554
+ * entity was not created by an element of this application - for example, a node inside a
555
+ * model's instantiated hierarchy, or an entity created through the engine API.
556
+ *
557
+ * @param entity - The entity to look up.
558
+ * @returns The element backing the entity, or `null`.
559
+ */
560
+ elementFromEntity(entity: Entity): EntityElement | null {
561
+ return this._entityElements.get(entity) ?? null;
562
+ }
563
+
564
+ /**
565
+ * Resolves the element that owns a picked node: the nearest node up the parent chain -
566
+ * starting with the node itself - that was created by a `<pc-entity>` of this application.
567
+ * A hit inside a model's instantiated hierarchy therefore resolves to the element hosting
568
+ * the model.
569
+ *
570
+ * @param node - The picked node, or `null`.
571
+ * @returns The owning element, or `null`.
572
+ */
573
+ private _elementFromNode(node: GraphNode | null): EntityElement | null {
574
+ while (node !== null) {
575
+ const element = this._entityElements.get(node);
576
+ if (element) {
577
+ return element;
578
+ }
579
+ node = node.parent;
580
+ }
581
+ return null;
582
+ }
583
+
584
+ /**
585
+ * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
586
+ * on an unlistened child still reaches a listening ancestor.
587
+ *
588
+ * @param node - The picked node, or `null`.
589
+ * @param type - The pointer event type a listener is required for.
590
+ * @returns The nearest listening element, or `null`.
591
+ */
592
+ private _elementWithListener(node: GraphNode | null, type: string): EntityElement | null {
593
+ while (node !== null) {
594
+ const element = this._entityElements.get(node);
595
+ if (element?._hasListeners(type)) {
596
+ return element;
597
+ }
598
+ node = node.parent;
599
+ }
600
+ return null;
601
+ }
602
+
430
603
  // New helper to convert CSS coordinates to canvas (picker) coordinates
431
- private _getPickerCoordinates(event: PointerEvent): { x: number, y: number } {
604
+ private _getPickerCoordinates(event: PointerEvent): { x: number; y: number } {
432
605
  // Get the canvas' bounding rectangle in CSS pixels.
433
606
  const canvasRect = this._canvas!.getBoundingClientRect();
434
607
  // Compute scale factors based on canvas actual resolution vs. its CSS display size.
@@ -465,7 +638,7 @@ class AppElement extends AsyncElement {
465
638
  return item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
466
639
  }
467
640
 
468
- async _onPointerMove(event: PointerEvent) {
641
+ private async _onPointerMove(event: PointerEvent) {
469
642
  if (!this._picker || !this.app) return;
470
643
 
471
644
  // Moves arrive faster than a pick resolves, so results can land out of order. Only the
@@ -475,24 +648,16 @@ class AppElement extends AsyncElement {
475
648
  const node = await this._pickNode(event);
476
649
  if (token !== this._pickToken || !this._picker) return;
477
650
 
478
- // Get the currently hovered entity by walking up the hierarchy
479
- let newHoverEntity: EntityElement | null = null;
480
- let currentNode = node;
481
- while (currentNode !== null) {
482
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
483
- if (entityElement) {
484
- newHoverEntity = entityElement;
485
- break;
486
- }
487
- currentNode = currentNode.parent;
488
- }
651
+ // The hovered element is the nearest one up the node's parent chain, listening or not -
652
+ // dispatch is gated per event type below
653
+ const newHoverEntity = this._elementFromNode(node);
489
654
 
490
655
  // Handle enter/leave events
491
656
  if (this._hoveredEntity !== newHoverEntity) {
492
- if (this._hoveredEntity && this._hoveredEntity.hasListeners('pointerleave')) {
657
+ if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
493
658
  this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
494
659
  }
495
- if (newHoverEntity && newHoverEntity.hasListeners('pointerenter')) {
660
+ if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
496
661
  newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
497
662
  }
498
663
  }
@@ -501,81 +666,103 @@ class AppElement extends AsyncElement {
501
666
  this._hoveredEntity = newHoverEntity;
502
667
 
503
668
  // Handle pointermove event
504
- if (newHoverEntity && newHoverEntity.hasListeners('pointermove')) {
669
+ if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
505
670
  newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
506
671
  }
507
672
  }
508
673
 
509
- async _onPointerDown(event: PointerEvent) {
674
+ private async _onPointerDown(event: PointerEvent) {
510
675
  if (!this._picker || !this.app) return;
511
676
 
512
- let currentNode = await this._pickNode(event);
677
+ const node = await this._pickNode(event);
513
678
  if (!this._picker) return; // the element disconnected while the pick was in flight
514
679
 
515
- while (currentNode !== null) {
516
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
517
- if (entityElement && entityElement.hasListeners('pointerdown')) {
518
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
519
- break;
520
- }
521
- currentNode = currentNode.parent;
680
+ const entityElement = this._elementWithListener(node, 'pointerdown');
681
+ if (entityElement) {
682
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
522
683
  }
523
684
  }
524
685
 
525
- async _onPointerUp(event: PointerEvent) {
686
+ private async _onPointerUp(event: PointerEvent) {
526
687
  if (!this._picker || !this.app) return;
527
688
 
528
689
  const node = await this._pickNode(event);
529
- if (!node || !this._picker) return;
690
+ if (!this._picker) return; // the element disconnected while the pick was in flight
530
691
 
531
- const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`) as EntityElement;
532
- if (entityElement && entityElement.hasListeners('pointerup')) {
692
+ const entityElement = this._elementWithListener(node, 'pointerup');
693
+ if (entityElement) {
533
694
  entityElement.dispatchEvent(new PointerEvent('pointerup', event));
534
695
  }
535
696
  }
536
697
 
537
- _onPointerListenerAdded(type: string) {
698
+ private _onPointerListenerAdded(type: string) {
538
699
  if (!this._hasPointerListeners[type] && this._canvas) {
539
700
  this._hasPointerListeners[type] = true;
540
701
 
541
702
  // For enter/leave events, we need the move handler
542
- const handler = (type === 'pointerenter' || type === 'pointerleave') ?
543
- this._pointerHandlers.pointermove :
544
- this._pointerHandlers[type];
703
+ const handler =
704
+ type === 'pointerenter' || type === 'pointerleave'
705
+ ? this._pointerHandlers.pointermove
706
+ : this._pointerHandlers[type];
545
707
 
546
708
  if (handler) {
547
- this._canvas.addEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
709
+ this._canvas.addEventListener(
710
+ type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type,
711
+ handler
712
+ );
548
713
  }
549
714
  }
550
715
  }
551
716
 
552
- _onPointerListenerRemoved(type: string) {
553
- const hasListeners = Array.from(this.querySelectorAll<EntityElement>('pc-entity'))
554
- .some(entity => entity.hasListeners(type));
717
+ private _onPointerListenerRemoved(type: string) {
718
+ const hasListeners = Array.from(this.querySelectorAll<EntityElement>('pc-entity')).some((entity) =>
719
+ entity._hasListeners(type)
720
+ );
555
721
 
556
722
  if (!hasListeners && this._canvas) {
557
723
  this._hasPointerListeners[type] = false;
558
724
 
559
- const handler = (type === 'pointerenter' || type === 'pointerleave') ?
560
- this._pointerHandlers.pointermove :
561
- this._pointerHandlers[type];
725
+ const handler =
726
+ type === 'pointerenter' || type === 'pointerleave'
727
+ ? this._pointerHandlers.pointermove
728
+ : this._pointerHandlers[type];
562
729
 
563
730
  if (handler) {
564
- this._canvas.removeEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
731
+ this._canvas.removeEventListener(
732
+ type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type,
733
+ handler
734
+ );
565
735
  }
566
736
  }
567
737
  }
568
738
 
569
739
  /**
570
- * Sets the alpha flag.
740
+ * Warns that a graphics option was written too late to have any effect. These options are read
741
+ * once, when the element connects and creates its graphics device, so a later write updates
742
+ * only the element's own property - silently, without this.
743
+ *
744
+ * @param name - The name of the option, as its attribute.
745
+ */
746
+ private _warnIfBooted(name: string) {
747
+ if (this._optionsLocked) {
748
+ console.warn(
749
+ `Attribute '${name}' on <pc-app> is only read when the application boots, so this change has no effect. Set it before the element is connected, or remove and re-insert the element to reboot with the new value.`
750
+ );
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Sets whether the frame buffer has an alpha channel, which is what lets the page show through
756
+ * wherever the scene has not drawn. Read only when the application boots.
571
757
  * @param value - The alpha flag.
572
758
  */
573
759
  set alpha(value: boolean) {
760
+ this._warnIfBooted('alpha');
574
761
  this._alpha = value;
575
762
  }
576
763
 
577
764
  /**
578
- * Gets the alpha flag.
765
+ * Gets whether the frame buffer has an alpha channel.
579
766
  * @returns The alpha flag.
580
767
  */
581
768
  get alpha() {
@@ -583,15 +770,16 @@ class AppElement extends AsyncElement {
583
770
  }
584
771
 
585
772
  /**
586
- * Sets the antialias flag.
773
+ * Sets whether the frame buffer is anti-aliased. Read only when the application boots.
587
774
  * @param value - The antialias flag.
588
775
  */
589
776
  set antialias(value: boolean) {
777
+ this._warnIfBooted('antialias');
590
778
  this._antialias = value;
591
779
  }
592
780
 
593
781
  /**
594
- * Gets the antialias flag.
782
+ * Gets whether the frame buffer is anti-aliased.
595
783
  * @returns The antialias flag.
596
784
  */
597
785
  get antialias() {
@@ -600,10 +788,11 @@ class AppElement extends AsyncElement {
600
788
 
601
789
  /**
602
790
  * Sets the graphics backend. Defaults to 'webgpu', which falls back to 'webgl2' if WebGPU
603
- * is not supported by the browser.
791
+ * is not supported by the browser. Read only when the application boots.
604
792
  * @param value - The graphics backend ('webgpu', 'webgl2', or 'null').
605
793
  */
606
794
  set backend(value: 'webgpu' | 'webgl2' | 'null') {
795
+ this._warnIfBooted('backend');
607
796
  this._backend = value;
608
797
  }
609
798
 
@@ -616,48 +805,21 @@ class AppElement extends AsyncElement {
616
805
  }
617
806
 
618
807
  /**
619
- * Sets the depth flag.
620
- * @param value - The depth flag.
808
+ * Sets whether the frame buffer has a depth buffer, which the renderer needs to resolve which
809
+ * surface is nearest the camera. Read only when the application boots.
810
+ * @param value - The depth buffer flag.
621
811
  */
622
- set depth(value: boolean) {
623
- this._depth = value;
812
+ set depthBuffer(value: boolean) {
813
+ this._warnIfBooted('depth-buffer');
814
+ this._depthBuffer = value;
624
815
  }
625
816
 
626
817
  /**
627
- * Gets the depth flag.
628
- * @returns The depth flag.
818
+ * Gets whether the frame buffer has a depth buffer.
819
+ * @returns The depth buffer flag.
629
820
  */
630
- get depth() {
631
- return this._depth;
632
- }
633
-
634
- /**
635
- * Gets the hierarchy ready flag.
636
- * @returns The hierarchy ready flag.
637
- * @ignore
638
- */
639
- get hierarchyReady() {
640
- return this._hierarchyReady;
641
- }
642
-
643
- /**
644
- * Sets the high resolution flag. When true, the application will render at the device's
645
- * physical resolution. When false, the application will render at CSS resolution.
646
- * @param value - The high resolution flag.
647
- */
648
- set highResolution(value: boolean) {
649
- this._highResolution = value;
650
- if (this.app) {
651
- this.app.graphicsDevice.maxPixelRatio = value ? window.devicePixelRatio : 1;
652
- }
653
- }
654
-
655
- /**
656
- * Gets the high resolution flag.
657
- * @returns The high resolution flag.
658
- */
659
- get highResolution() {
660
- return this._highResolution;
821
+ get depthBuffer() {
822
+ return this._depthBuffer;
661
823
  }
662
824
 
663
825
  /**
@@ -686,23 +848,49 @@ class AppElement extends AsyncElement {
686
848
  }
687
849
 
688
850
  /**
689
- * Sets the stencil flag.
690
- * @param value - The stencil flag.
851
+ * Sets the cap on the pixel ratio the application renders at. The canvas is sized by the
852
+ * smaller of this value and the display's own device pixel ratio, so the default of `Infinity`
853
+ * renders at full physical resolution, `1` renders at CSS resolution, and an intermediate
854
+ * value such as `2` keeps a dense display sharp without paying for every one of its pixels.
855
+ * Must be greater than 0. Unlike the other graphics options, this applies immediately.
856
+ * @param value - The maximum pixel ratio.
691
857
  */
692
- set stencil(value: boolean) {
693
- this._stencil = value;
858
+ set maxPixelRatio(value: number) {
859
+ this._maxPixelRatio = value;
860
+ if (this.app) {
861
+ this.app.graphicsDevice.maxPixelRatio = value;
862
+ this.app.resizeCanvas();
863
+ }
694
864
  }
695
865
 
696
866
  /**
697
- * Gets the stencil flag.
698
- * @returns The stencil flag.
867
+ * Gets the cap on the pixel ratio the application renders at.
868
+ * @returns The maximum pixel ratio.
699
869
  */
700
- get stencil() {
701
- return this._stencil;
870
+ get maxPixelRatio() {
871
+ return this._maxPixelRatio;
872
+ }
873
+
874
+ /**
875
+ * Sets whether the frame buffer has a stencil buffer, which stencil-based effects and UI
876
+ * masking need. Read only when the application boots.
877
+ * @param value - The stencil buffer flag.
878
+ */
879
+ set stencilBuffer(value: boolean) {
880
+ this._warnIfBooted('stencil-buffer');
881
+ this._stencilBuffer = value;
882
+ }
883
+
884
+ /**
885
+ * Gets whether the frame buffer has a stencil buffer.
886
+ * @returns The stencil buffer flag.
887
+ */
888
+ get stencilBuffer() {
889
+ return this._stencilBuffer;
702
890
  }
703
891
 
704
892
  static get observedAttributes() {
705
- return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution', 'loading-bar'];
893
+ return ['alpha', 'antialias', 'backend', 'depth-buffer', 'loading-bar', 'max-pixel-ratio', 'stencil-buffer'];
706
894
  }
707
895
 
708
896
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
@@ -716,17 +904,17 @@ class AppElement extends AsyncElement {
716
904
  case 'backend':
717
905
  this.backend = parseEnum(newValue, ['webgpu', 'webgl2', 'null'], 'webgpu', name);
718
906
  break;
719
- case 'depth':
720
- this.depth = parseBool(newValue, true);
721
- break;
722
- case 'high-resolution':
723
- this.highResolution = parseBool(newValue, true);
907
+ case 'depth-buffer':
908
+ this.depthBuffer = parseBool(newValue, true);
724
909
  break;
725
910
  case 'loading-bar':
726
911
  this.loadingBar = parseBool(newValue, true);
727
912
  break;
728
- case 'stencil':
729
- this.stencil = parseBool(newValue, true);
913
+ case 'max-pixel-ratio':
914
+ this.maxPixelRatio = parseNumber(newValue, Infinity, name);
915
+ break;
916
+ case 'stencil-buffer':
917
+ this.stencilBuffer = parseBool(newValue, true);
730
918
  break;
731
919
  }
732
920
  }
@@ -734,10 +922,4 @@ class AppElement extends AsyncElement {
734
922
 
735
923
  customElements.define('pc-app', AppElement);
736
924
 
737
- declare global {
738
- interface HTMLElementTagNameMap {
739
- 'pc-app': AppElement;
740
- }
741
- }
742
-
743
925
  export { AppElement };