From e6a58e5d8d9514b257adaa47cc0a1145608cf764 Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Fri, 13 Dec 2024 15:16:17 +0200 Subject: [PATCH 1/9] optimize render component rootBone property --- src/framework/components/render/component.js | 80 ++++++++++---------- src/framework/components/render/data.js | 1 - src/framework/components/render/system.js | 4 +- 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/framework/components/render/component.js b/src/framework/components/render/component.js index d0b47aca093..aa45faf44ee 100644 --- a/src/framework/components/render/component.js +++ b/src/framework/components/render/component.js @@ -12,8 +12,6 @@ import { AssetReference } from '../../asset/asset-reference.js'; import { Component } from '../component.js'; -import { EntityReference } from '../../utils/entity-reference.js'; - /** * The RenderComponent enables an {@link Entity} to render 3D meshes. The {@link RenderComponent#type} * property can be set to one of several predefined shape types (such as `box`, `sphere`, `cone` @@ -54,9 +52,6 @@ import { EntityReference } from '../../utils/entity-reference.js'; * - [Primitive Shapes](https://playcanvas.github.io/#/graphics/shapes) * - [Loading Render Assets](https://playcanvas.github.io/#/graphics/render-asset) * - * @property {import('../../entity.js').Entity} rootBone A reference to the entity to be used as - * the root bone for any skinned meshes that are rendered by this component. - * * @category Graphics */ class RenderComponent extends Component { @@ -136,10 +131,12 @@ class RenderComponent extends Component { _material; /** - * @type {EntityReference} - * @private + * A reference to the entity to be used as the root bone for any skinned meshes that + * are rendered by this component. + * + * @type {import('../../entity.js').Entity|null} */ - _rootBone; + _rootBone = null; /** * @type {import('../../../core/event-handle.js').EventHandle|null} @@ -177,8 +174,6 @@ class RenderComponent extends Component { super(system, entity); // the entity that represents the root bone if this render component has skinned meshes - this._rootBone = new EntityReference(this, 'rootBone'); - this._rootBone.on('set:entity', this._onSetRootBone, this); // render asset reference this._assetReference = new AssetReference( @@ -275,7 +270,6 @@ class RenderComponent extends Component { * @type {string} */ set type(value) { - if (this._type !== value) { this._area = null; this._type = value; @@ -312,7 +306,6 @@ class RenderComponent extends Component { * @type {MeshInstance[]} */ set meshInstances(value) { - Debug.assert(Array.isArray(value), 'MeshInstances set to a Render component must be an array.'); this.destroyMeshInstances(); @@ -702,28 +695,37 @@ class RenderComponent extends Component { this._assetReference.id = id; } - /** - * @param {import('../../entity.js').Entity} entity - The entity set as the root bone. - * @private - */ - _onSetRootBone(entity) { - if (entity) { - this._onRootBoneChanged(); + set rootBone(value) { + if (this._rootBone !== value) { + const isString = typeof (value) === 'string'; + if (this._rootBone && isString && this._rootBone.getGuid() === value) { + return; + } + + if (this._rootBone) { + this._clearSkinInstances(); + } + + if (value instanceof GraphNode) { + this._rootBone = value; + } else if (isString) { + this._rootBone = this.system.app.getEntityFromIndex(value) || null; + } else { + this._rootBone = null; + } + + if (this._rootBone) { + this._cloneSkinInstances(); + } } } - /** @private */ - _onRootBoneChanged() { - // remove existing skin instances and create new ones, connected to new root bone - this._clearSkinInstances(); - if (this.enabled && this.entity.enabled) { - this._cloneSkinInstances(); - } + get rootBone() { + return this._rootBone; } /** @private */ destroyMeshInstances() { - const meshInstances = this._meshInstances; if (meshInstances) { this.removeFromLayers(); @@ -814,9 +816,9 @@ class RenderComponent extends Component { const scene = app.scene; const layers = scene.layers; - this._rootBone.onParentComponentEnable(); - - this._cloneSkinInstances(); + if (this._rootBone) { + this._cloneSkinInstances(); + } this._evtLayersChanged = scene.on('set:layers', this.onLayersChanged, this); @@ -852,6 +854,10 @@ class RenderComponent extends Component { this._evtLayersChanged?.off(); this._evtLayersChanged = null; + if (this._rootBone) { + this._clearSkinInstances(); + } + if (layers) { this._evtLayerAdded?.off(); this._evtLayerAdded = null; @@ -904,7 +910,6 @@ class RenderComponent extends Component { } _onRenderAssetLoad() { - // remove existing instances this.destroyMeshInstances(); @@ -923,7 +928,6 @@ class RenderComponent extends Component { } _clearSkinInstances() { - for (let i = 0; i < this._meshInstances.length; i++) { const meshInstance = this._meshInstances[i]; @@ -934,23 +938,20 @@ class RenderComponent extends Component { } _cloneSkinInstances() { - - if (this._meshInstances.length && this._rootBone.entity instanceof GraphNode) { - + if (this._meshInstances.length && this._rootBone instanceof GraphNode) { for (let i = 0; i < this._meshInstances.length; i++) { const meshInstance = this._meshInstances[i]; const mesh = meshInstance.mesh; // if skinned but does not have instance created yet if (mesh.skin && !meshInstance.skinInstance) { - meshInstance.skinInstance = SkinInstanceCache.createCachedSkinInstance(mesh.skin, this._rootBone.entity, this.entity); + meshInstance.skinInstance = SkinInstanceCache.createCachedSkinInstance(mesh.skin, this._rootBone, this.entity); } } } } _cloneMeshes(meshes) { - if (meshes && meshes.length) { // cloned mesh instances @@ -1031,10 +1032,9 @@ class RenderComponent extends Component { } resolveDuplicatedEntityReferenceProperties(oldRender, duplicatedIdsMap) { - if (oldRender.rootBone && duplicatedIdsMap[oldRender.rootBone]) { - this.rootBone = duplicatedIdsMap[oldRender.rootBone]; + if (oldRender.rootBone) { + this.rootBone = duplicatedIdsMap[oldRender.rootBone.getGuid()]; } - this._clearSkinInstances(); } } diff --git a/src/framework/components/render/data.js b/src/framework/components/render/data.js index 5ff36ded89e..53ac9f97c13 100644 --- a/src/framework/components/render/data.js +++ b/src/framework/components/render/data.js @@ -1,7 +1,6 @@ class RenderComponentData { constructor() { this.enabled = true; - this.rootBone = null; } } diff --git a/src/framework/components/render/system.js b/src/framework/components/render/system.js index 12688a94cb1..df3d4484cc6 100644 --- a/src/framework/components/render/system.js +++ b/src/framework/components/render/system.js @@ -11,7 +11,6 @@ import { RenderComponent } from './component.js'; import { RenderComponentData } from './data.js'; const _schema = [ - { name: 'rootBone', type: 'entity' }, 'enabled' ]; @@ -30,7 +29,8 @@ const _properties = [ 'type', 'layers', 'isStatic', - 'batchGroupId' + 'batchGroupId', + 'rootBone' ]; /** From f5ba0673aed43bd1cd800ab5b243365e0cfabb71 Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Mon, 16 Dec 2024 12:47:09 +0200 Subject: [PATCH 2/9] expose rootBone property --- src/framework/components/render/component.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/framework/components/render/component.js b/src/framework/components/render/component.js index aa45faf44ee..e04b35c0a26 100644 --- a/src/framework/components/render/component.js +++ b/src/framework/components/render/component.js @@ -135,6 +135,7 @@ class RenderComponent extends Component { * are rendered by this component. * * @type {import('../../entity.js').Entity|null} + * @private */ _rootBone = null; @@ -695,6 +696,11 @@ class RenderComponent extends Component { this._assetReference.id = id; } + /** + * Sets the root bone entity (or entity guid) for the render component. + * + * @type {import('../../entity.js').Entity|string|null} + */ set rootBone(value) { if (this._rootBone !== value) { const isString = typeof (value) === 'string'; @@ -720,6 +726,11 @@ class RenderComponent extends Component { } } + /** + * Gets the root bone entity for the render component. + * + * @type {import('../../entity.js').Entity|null} + */ get rootBone() { return this._rootBone; } From f396776221137efa1ab0f3923f0390c73364b095 Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Mon, 16 Dec 2024 12:54:14 +0200 Subject: [PATCH 3/9] Update src/framework/components/render/component.js Co-authored-by: Will Eastcott --- src/framework/components/render/component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framework/components/render/component.js b/src/framework/components/render/component.js index e04b35c0a26..b36d1ef5952 100644 --- a/src/framework/components/render/component.js +++ b/src/framework/components/render/component.js @@ -703,7 +703,7 @@ class RenderComponent extends Component { */ set rootBone(value) { if (this._rootBone !== value) { - const isString = typeof (value) === 'string'; + const isString = typeof value === 'string'; if (this._rootBone && isString && this._rootBone.getGuid() === value) { return; } From 26a267d92b38b27e011030cf438b7b72134853ca Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Mon, 16 Dec 2024 13:00:45 +0200 Subject: [PATCH 4/9] use jsdo import --- src/framework/components/render/component.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/framework/components/render/component.js b/src/framework/components/render/component.js index e04b35c0a26..401c7848cec 100644 --- a/src/framework/components/render/component.js +++ b/src/framework/components/render/component.js @@ -12,6 +12,10 @@ import { AssetReference } from '../../asset/asset-reference.js'; import { Component } from '../component.js'; +/** + * @import { Entity } from '../../entity.js' + */ + /** * The RenderComponent enables an {@link Entity} to render 3D meshes. The {@link RenderComponent#type} * property can be set to one of several predefined shape types (such as `box`, `sphere`, `cone` @@ -134,7 +138,7 @@ class RenderComponent extends Component { * A reference to the entity to be used as the root bone for any skinned meshes that * are rendered by this component. * - * @type {import('../../entity.js').Entity|null} + * @type {Entity|null} * @private */ _rootBone = null; @@ -168,7 +172,7 @@ class RenderComponent extends Component { * * @param {import('./system.js').RenderComponentSystem} system - The ComponentSystem that * created this Component. - * @param {import('../../entity.js').Entity} entity - The Entity that this Component is + * @param {Entity} entity - The Entity that this Component is * attached to. */ constructor(system, entity) { @@ -699,7 +703,7 @@ class RenderComponent extends Component { /** * Sets the root bone entity (or entity guid) for the render component. * - * @type {import('../../entity.js').Entity|string|null} + * @type {Entity|string|null} */ set rootBone(value) { if (this._rootBone !== value) { @@ -729,7 +733,7 @@ class RenderComponent extends Component { /** * Gets the root bone entity for the render component. * - * @type {import('../../entity.js').Entity|null} + * @type {Entity|null} */ get rootBone() { return this._rootBone; From dbfcf30b56771e914dee654c8224601859e77707 Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Mon, 16 Dec 2024 13:12:33 +0200 Subject: [PATCH 5/9] . --- src/framework/components/render/component.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/framework/components/render/component.js b/src/framework/components/render/component.js index 745e74204e1..67de4df25bd 100644 --- a/src/framework/components/render/component.js +++ b/src/framework/components/render/component.js @@ -172,8 +172,7 @@ class RenderComponent extends Component { * * @param {import('./system.js').RenderComponentSystem} system - The ComponentSystem that * created this Component. - * @param {Entity} entity - The Entity that this Component is - * attached to. + * @param {Entity} entity - The Entity that this Component is attached to. */ constructor(system, entity) { super(system, entity); From d32fb52e676ef39b4811060b18871fbabbe8bf78 Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Mon, 16 Dec 2024 16:51:28 +0200 Subject: [PATCH 6/9] ButtonComponent remove EntityReference --- src/framework/components/button/component.js | 233 ++++++++++++++----- src/framework/components/button/system.js | 2 +- src/framework/components/element/system.js | 5 + src/framework/components/system.js | 1 + src/framework/entity.js | 5 + 5 files changed, 189 insertions(+), 57 deletions(-) diff --git a/src/framework/components/button/component.js b/src/framework/components/button/component.js index e734a0192b6..4ca14e7762b 100644 --- a/src/framework/components/button/component.js +++ b/src/framework/components/button/component.js @@ -3,13 +3,18 @@ import { now } from '../../../core/time.js'; import { math } from '../../../core/math/math.js'; import { Color } from '../../../core/math/color.js'; -import { EntityReference } from '../../utils/entity-reference.js'; +import { GraphNode } from '../../../scene/graph-node.js'; import { Component } from '../component.js'; import { BUTTON_TRANSITION_MODE_SPRITE_CHANGE, BUTTON_TRANSITION_MODE_TINT } from './constants.js'; import { ELEMENTTYPE_GROUP } from '../element/constants.js'; +/** + * @import { EventHandle } from '../../../core/event-handle.js' + * @import { Entity } from '../../entity.js' + */ + const VisualState = { DEFAULT: 'DEFAULT', HOVER: 'HOVER', @@ -239,13 +244,54 @@ class ButtonComponent extends Component { */ static EVENT_PRESSEDEND = 'pressedend'; + /** + * @type {Entity|null} + * @private + */ + _imageEntity = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtImageEntityElementAdd = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtImageEntityElementRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtImageEntityElementColor = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtImageEntityElementOpacity = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtImageEntityElementSpriteAsset = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtImageEntityElementSpriteFrame = null; + /** * Create a new ButtonComponent instance. * * @param {import('./system.js').ButtonComponentSystem} system - The ComponentSystem that * created this component. - * @param {import('../../entity.js').Entity} entity - The entity that this component is - * attached to. + * @param {Entity} entity - The entity that this component is attached to. */ constructor(system, entity) { super(system, entity); @@ -259,15 +305,6 @@ class ButtonComponent extends Component { this._defaultSpriteAsset = null; this._defaultSpriteFrame = 0; - this._imageReference = new EntityReference(this, 'imageEntity', { - 'element#gain': this._onImageElementGain, - 'element#lose': this._onImageElementLose, - 'element#set:color': this._onSetColor, - 'element#set:opacity': this._onSetOpacity, - 'element#set:spriteAsset': this._onSetSpriteAsset, - 'element#set:spriteFrame': this._onSetSpriteFrame - }); - this._toggleLifecycleListeners('on', system); } @@ -322,19 +359,46 @@ class ButtonComponent extends Component { * Sets the entity to be used as the button background. The entity must have an * {@link ElementComponent} configured as an image element. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|string|null} */ set imageEntity(arg) { - this._setValue('imageEntity', arg); + if (this._imageEntity !== arg) { + const isString = typeof arg === 'string'; + if (this._imageEntity && isString && this._imageEntity.getGuid() === arg) { + return; + } + + if (this._imageEntity) { + this._imageEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._imageEntity = arg; + } else if (isString) { + this._imageEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._imageEntity = null; + } + + if (this._imageEntity) { + this._imageEntitySubscribe(); + } + + if (this._imageEntity) { + this.data.imageEntity = this._imageEntity.getGuid(); + } else if (isString && arg) { + this.data.imageEntity = arg; + } + } } /** * Gets the entity to be used as the button background. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|null} */ get imageEntity() { - return this.data.imageEntity; + return this._imageEntity; } /** @@ -608,6 +672,50 @@ class ButtonComponent extends Component { } } + _imageEntitySubscribe() { + this._evtImageEntityElementAdd = this._imageEntity.on('element:add', this._onImageElementGain, this); + + if (this._imageEntity.element) { + this._onImageElementGain(); + } + } + + _imageEntityUnsubscribe() { + this._evtImageEntityElementAdd?.off(); + this._evtImageEntityElementAdd = null; + + if (this._imageEntity?.element) { + this._onImageElementLose(); + } + } + + _imageEntityElementSubscribe() { + const element = this._imageEntity.element; + + this._evtImageEntityElementRemove = element.once('beforeremove', this._onImageElementLose, this); + this._evtImageEntityElementColor = element.on('set:color', this._onSetColor, this); + this._evtImageEntityElementOpacity = element.on('set:opacity', this._onSetOpacity, this); + this._evtImageEntityElementSpriteAsset = element.on('set:spriteAsset', this._onSetSpriteAsset, this); + this._evtImageEntityElementSpriteFrame = element.on('set:spriteFrame', this._onSetSpriteFrame, this); + } + + _imageEntityElementUnsubscribe() { + this._evtImageEntityElementRemove?.off(); + this._evtImageEntityElementRemove = null; + + this._evtImageEntityElementColor?.off(); + this._evtImageEntityElementColor = null; + + this._evtImageEntityElementOpacity?.off(); + this._evtImageEntityElementOpacity = null; + + this._evtImageEntityElementSpriteAsset?.off(); + this._evtImageEntityElementSpriteAsset = null; + + this._evtImageEntityElementSpriteFrame?.off(); + this._evtImageEntityElementSpriteFrame = null; + } + _onElementComponentRemove(entity) { if (this.entity === entity) { this._toggleHitElementListeners('off'); @@ -621,11 +729,13 @@ class ButtonComponent extends Component { } _onImageElementLose() { + this._imageEntityElementUnsubscribe(); this._cancelTween(); this._resetToDefaultVisualState(this.transitionMode); } _onImageElementGain() { + this._imageEntityElementSubscribe(); this._storeDefaultVisualState(); this._forceReapplyVisualState(); } @@ -659,15 +769,14 @@ class ButtonComponent extends Component { _storeDefaultVisualState() { // If the element is of group type, all it's visual properties are null - if (this._imageReference.hasComponent('element')) { - const element = this._imageReference.entity.element; - if (element.type !== ELEMENTTYPE_GROUP) { - this._storeDefaultColor(element.color); - this._storeDefaultOpacity(element.opacity); - this._storeDefaultSpriteAsset(element.spriteAsset); - this._storeDefaultSpriteFrame(element.spriteFrame); - } + const element = this._imageEntity?.element; + if (!element || element.type === ELEMENTTYPE_GROUP) { + return; } + this._storeDefaultColor(element.color); + this._storeDefaultOpacity(element.opacity); + this._storeDefaultSpriteAsset(element.spriteAsset); + this._storeDefaultSpriteFrame(element.spriteFrame); } _storeDefaultColor(color) { @@ -881,17 +990,18 @@ class ButtonComponent extends Component { // image back to its original tint. Note that this happens immediately, i.e. // without any animation. _resetToDefaultVisualState(transitionMode) { - if (this._imageReference.hasComponent('element')) { - switch (transitionMode) { - case BUTTON_TRANSITION_MODE_TINT: - this._cancelTween(); - this._applyTintImmediately(this._defaultTint); - break; - - case BUTTON_TRANSITION_MODE_SPRITE_CHANGE: - this._applySprite(this._defaultSpriteAsset, this._defaultSpriteFrame); - break; - } + if (!this._imageEntity?.element) { + return; + } + switch (transitionMode) { + case BUTTON_TRANSITION_MODE_TINT: + this._cancelTween(); + this._applyTintImmediately(this._defaultTint); + break; + + case BUTTON_TRANSITION_MODE_SPRITE_CHANGE: + this._applySprite(this._defaultSpriteAsset, this._defaultSpriteFrame); + break; } } @@ -908,21 +1018,24 @@ class ButtonComponent extends Component { } _applySprite(spriteAsset, spriteFrame) { - spriteFrame = spriteFrame || 0; + const element = this._imageEntity?.element; + if (!element) { + return; + } - if (this._imageReference.hasComponent('element')) { - this._isApplyingSprite = true; + spriteFrame = spriteFrame || 0; - if (this._imageReference.entity.element.spriteAsset !== spriteAsset) { - this._imageReference.entity.element.spriteAsset = spriteAsset; - } + this._isApplyingSprite = true; - if (this._imageReference.entity.element.spriteFrame !== spriteFrame) { - this._imageReference.entity.element.spriteFrame = spriteFrame; - } + if (element.spriteAsset !== spriteAsset) { + element.spriteAsset = spriteAsset; + } - this._isApplyingSprite = false; + if (element.spriteFrame !== spriteFrame) { + element.spriteFrame = spriteFrame; } + + this._isApplyingSprite = false; } _applyTint(tintColor) { @@ -936,10 +1049,11 @@ class ButtonComponent extends Component { } _applyTintImmediately(tintColor) { + const element = this._imageEntity?.element; if ( !tintColor || - !this._imageReference.hasComponent('element') || - this._imageReference.entity.element.type === ELEMENTTYPE_GROUP + !element || + element.type === ELEMENTTYPE_GROUP ) { return; } @@ -948,29 +1062,30 @@ class ButtonComponent extends Component { this._isApplyingTint = true; - if (!color3.equals(this._imageReference.entity.element.color)) { - this._imageReference.entity.element.color = color3; + if (!color3.equals(element.color)) { + element.color = color3; } - if (this._imageReference.entity.element.opacity !== tintColor.a) { - this._imageReference.entity.element.opacity = tintColor.a; + if (element.opacity !== tintColor.a) { + element.opacity = tintColor.a; } this._isApplyingTint = false; } _applyTintWithTween(tintColor) { + const element = this._imageEntity?.element; if ( !tintColor || - !this._imageReference.hasComponent('element') || - this._imageReference.entity.element.type === ELEMENTTYPE_GROUP + !element || + element.type === ELEMENTTYPE_GROUP ) { return; } const color3 = toColor3(tintColor); - const color = this._imageReference.entity.element.color; - const opacity = this._imageReference.entity.element.opacity; + const color = element.color; + const opacity = element.opacity; if (color3.equals(color) && tintColor.a === opacity) return; @@ -1015,7 +1130,6 @@ class ButtonComponent extends Component { this._hoveringCounter = 0; this._isPressed = false; - this._imageReference.onParentComponentEnable(); this._toggleHitElementListeners('on'); this._forceReapplyVisualState(); } @@ -1026,9 +1140,16 @@ class ButtonComponent extends Component { } onRemove() { + this._imageEntityUnsubscribe(); this._toggleLifecycleListeners('off', this.system); this.onDisable(); } + + resolveDuplicatedEntityReferenceProperties(oldButton, duplicatedIdsMap) { + if (oldButton.imageEntity) { + this.imageEntity = duplicatedIdsMap[oldButton.imageEntity.getGuid()]; + } + } } function toColor3(color4) { diff --git a/src/framework/components/button/system.js b/src/framework/components/button/system.js index bc0cc2e6e97..48ebb393e41 100644 --- a/src/framework/components/button/system.js +++ b/src/framework/components/button/system.js @@ -6,7 +6,6 @@ import { ButtonComponentData } from './data.js'; const _schema = [ 'enabled', 'active', - { name: 'imageEntity', type: 'entity' }, { name: 'hitPadding', type: 'vec4' }, 'transitionMode', { name: 'hoverTint', type: 'rgba' }, @@ -49,6 +48,7 @@ class ButtonComponentSystem extends ComponentSystem { } initializeComponentData(component, data, properties) { + component.imageEntity = data.imageEntity; super.initializeComponentData(component, data, _schema); } diff --git a/src/framework/components/element/system.js b/src/framework/components/element/system.js index 8a9550613e5..f5334af6fde 100644 --- a/src/framework/components/element/system.js +++ b/src/framework/components/element/system.js @@ -77,6 +77,7 @@ class ElementComponentSystem extends ComponentSystem { this.defaultImageMaterials = []; + this.on('add', this.onAddComponent, this); this.on('beforeremove', this.onRemoveComponent, this); } @@ -269,6 +270,10 @@ class ElementComponentSystem extends ComponentSystem { } } + onAddComponent(entity, component) { + entity.fire('element:add'); + } + onRemoveComponent(entity, component) { component.onRemove(); } diff --git a/src/framework/components/system.js b/src/framework/components/system.js index de3e69a6ac7..c9ed2386c57 100644 --- a/src/framework/components/system.js +++ b/src/framework/components/system.js @@ -70,6 +70,7 @@ class ComponentSystem extends EventHandler { const record = this.store[entity.getGuid()]; const component = entity.c[this.id]; + component.fire('beforeremove'); this.fire('beforeremove', entity, component); delete this.store[entity.getGuid()]; diff --git a/src/framework/entity.js b/src/framework/entity.js index 9b21d226fe6..3b4df14c2ac 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -680,6 +680,11 @@ function resolveDuplicatedEntityReferenceProperties(oldSubtreeRoot, oldEntity, n newEntity.render.resolveDuplicatedEntityReferenceProperties(components.render, duplicatedIdsMap); } + // Handle entity button attributes + if (components.button) { + newEntity.button.resolveDuplicatedEntityReferenceProperties(components.button, duplicatedIdsMap); + } + // Handle entity anim attributes if (components.anim) { newEntity.anim.resolveDuplicatedEntityReferenceProperties(components.anim, duplicatedIdsMap); From 5f134f52289a1e7e3bd8f44b9f6df9590bcbda8a Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Mon, 16 Dec 2024 17:40:28 +0200 Subject: [PATCH 7/9] remove reliance on component system global event lists --- src/framework/components/button/component.js | 27 ++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/framework/components/button/component.js b/src/framework/components/button/component.js index 4ca14e7762b..91648484720 100644 --- a/src/framework/components/button/component.js +++ b/src/framework/components/button/component.js @@ -250,6 +250,12 @@ class ButtonComponent extends Component { */ _imageEntity = null; + /** + * @type {EventHandle|null} + * @private + */ + _evtElementAdd = null; + /** * @type {EventHandle|null} * @private @@ -648,8 +654,12 @@ class ButtonComponent extends Component { this[onOrOff]('set_inactiveSpriteAsset', this._onSetTransitionValue, this); this[onOrOff]('set_inactiveSpriteFrame', this._onSetTransitionValue, this); - system.app.systems.element[onOrOff]('add', this._onElementComponentAdd, this); - system.app.systems.element[onOrOff]('beforeremove', this._onElementComponentRemove, this); + if (onOrOff === 'on') { + this._evtElementAdd = this.entity.on('element:add', this._onElementComponentAdd, this); + } else { + this._evtElementAdd?.off(); + this._evtElementAdd = null; + } } _onSetActive(name, oldValue, newValue) { @@ -716,16 +726,12 @@ class ButtonComponent extends Component { this._evtImageEntityElementSpriteFrame = null; } - _onElementComponentRemove(entity) { - if (this.entity === entity) { - this._toggleHitElementListeners('off'); - } + _onElementComponentRemove() { + this._toggleHitElementListeners('off'); } - _onElementComponentAdd(entity) { - if (this.entity === entity) { - this._toggleHitElementListeners('on'); - } + _onElementComponentAdd() { + this._toggleHitElementListeners('on'); } _onImageElementLose() { @@ -749,6 +755,7 @@ class ButtonComponent extends Component { return; } + this.entity.element[onOrOff]('beforeremove', this._onElementComponentRemove, this); this.entity.element[onOrOff]('mouseenter', this._onMouseEnter, this); this.entity.element[onOrOff]('mouseleave', this._onMouseLeave, this); this.entity.element[onOrOff]('mousedown', this._onMouseDown, this); From d047c3f04e92549d8c43ed2da3748ef692863058 Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Tue, 17 Dec 2024 12:43:12 +0200 Subject: [PATCH 8/9] remove EntityReference from ScrollbarComponent --- .../components/scrollbar/component.js | 131 ++++++++++++++---- src/framework/components/scrollbar/data.js | 2 +- src/framework/components/scrollbar/system.js | 4 +- src/framework/entity.js | 5 + 4 files changed, 115 insertions(+), 27 deletions(-) diff --git a/src/framework/components/scrollbar/component.js b/src/framework/components/scrollbar/component.js index f2869185de8..e572658bf80 100644 --- a/src/framework/components/scrollbar/component.js +++ b/src/framework/components/scrollbar/component.js @@ -2,11 +2,16 @@ import { math } from '../../../core/math/math.js'; import { ORIENTATION_HORIZONTAL } from '../../../scene/constants.js'; +import { GraphNode } from '../../../scene/graph-node.js'; + import { Component } from '../component.js'; import { ElementDragHelper } from '../element/element-drag-helper.js'; -import { EntityReference } from '../../utils/entity-reference.js'; +/** + * @import { EventHandle } from '../../../core/event-handle.js' + * @import { Entity } from '../../entity.js' + */ /** * A ScrollbarComponent enables a group of entities to behave like a draggable scrollbar. @@ -26,25 +31,33 @@ class ScrollbarComponent extends Component { */ static EVENT_SETVALUE = 'set:value'; + /** + * @type {Entity|null} + * @private + */ + _handleEntity = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHandleEntityElementAdd = null; + + /** + * @type {EventHandle[]} + * @private + */ + _evtHandleEntityChanges = []; + /** * Create a new ScrollbarComponent. * * @param {import('./system.js').ScrollbarComponentSystem} system - The ComponentSystem that * created this Component. - * @param {import('../../entity.js').Entity} entity - The Entity that this Component is - * attached to. + * @param {Entity} entity - The Entity that this Component is attached to. */ constructor(system, entity) { super(system, entity); - - this._handleReference = new EntityReference(this, 'handleEntity', { - 'element#gain': this._onHandleElementGain, - 'element#lose': this._onHandleElementLose, - 'element#set:anchor': this._onSetHandleAlignment, - 'element#set:margin': this._onSetHandleAlignment, - 'element#set:pivot': this._onSetHandleAlignment - }); - this._toggleLifecycleListeners('on'); } @@ -141,19 +154,48 @@ class ScrollbarComponent extends Component { * Sets the entity to be used as the scrollbar handle. This entity must have a * {@link ScrollbarComponent}. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|string|null} */ set handleEntity(arg) { - this._setValue('handleEntity', arg); + if (this._handleEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._handleEntity && isString && this._handleEntity.getGuid() === arg) { + return; + } + + if (this._handleEntity) { + this._handleEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._handleEntity = arg; + } else if (isString) { + this._handleEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._handleEntity = null; + } + + if (this._handleEntity) { + this._handleEntitySubscribe(); + } + + if (this._handleEntity) { + this.data.handleEntity = this._handleEntity.getGuid(); + } else if (isString && arg) { + this.data.handleEntity = arg; + } } /** * Gets the entity to be used as the scrollbar handle. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|null} */ get handleEntity() { - return this.data.handleEntity; + return this._handleEntity; } /** @ignore */ @@ -176,20 +218,56 @@ class ScrollbarComponent extends Component { // TODO Handle scrollwheel events } + _handleEntitySubscribe() { + this._evtHandleEntityElementAdd = this._handleEntity.on('element:add', this._onHandleElementGain, this); + + if (this._handleEntity.element) { + this._onHandleElementGain(); + } + } + + _handleEntityUnsubscribe() { + this._evtHandleEntityElementAdd?.off(); + this._evtHandleEntityElementAdd = null; + + if (this._handleEntity?.element) { + this._onHandleElementLose(); + } + } + + _handleEntityElementSubscribe() { + const element = this._handleEntity.element; + + const handles = this._evtHandleEntityChanges; + handles.push(element.once('beforeremove', this._onHandleElementLose, this)); + handles.push(element.on('set:anchor', this._onSetHandleAlignment, this)); + handles.push(element.on('set:margin', this._onSetHandleAlignment, this)); + handles.push(element.on('set:pivot', this._onSetHandleAlignment, this)); + } + + _handleEntityElementUnsubscribe() { + for (let i = 0; i < this._evtHandleEntityChanges.length; i++) { + this._evtHandleEntityChanges[i].off(); + } + this._evtHandleEntityChanges.length = 0; + } + _onHandleElementGain() { + this._handleEntityElementSubscribe(); this._destroyDragHelper(); - this._handleDragHelper = new ElementDragHelper(this._handleReference.entity.element, this._getAxis()); + this._handleDragHelper = new ElementDragHelper(this._handleEntity.element, this._getAxis()); this._handleDragHelper.on('drag:move', this._onHandleDrag, this); this._updateHandlePositionAndSize(); } _onHandleElementLose() { + this._handleEntityElementUnsubscribe(); this._destroyDragHelper(); } _onHandleDrag(position) { - if (this._handleReference.entity && this.enabled && this.entity.enabled) { + if (this._handleEntity && this.enabled && this.entity.enabled) { this.value = this._handlePositionToScrollValue(position[this._getAxis()]); } } @@ -214,19 +292,19 @@ class ScrollbarComponent extends Component { } _onSetOrientation(name, oldValue, newValue) { - if (newValue !== oldValue && this._handleReference.hasComponent('element')) { - this._handleReference.entity.element[this._getOppositeDimension()] = 0; + if (newValue !== oldValue && this._handleEntity?.element) { + this._handleEntity.element[this._getOppositeDimension()] = 0; } } _updateHandlePositionAndSize() { - const handleEntity = this._handleReference.entity; - const handleElement = handleEntity && handleEntity.element; + const handleEntity = this._handleEntity; + const handleElement = handleEntity?.element; if (handleEntity) { const position = handleEntity.getLocalPosition(); position[this._getAxis()] = this._getHandlePosition(); - this._handleReference.entity.setLocalPosition(position); + handleEntity.setLocalPosition(position); } if (handleElement) { @@ -291,7 +369,6 @@ class ScrollbarComponent extends Component { } onEnable() { - this._handleReference.onParentComponentEnable(); this._setHandleDraggingEnabled(true); } @@ -303,6 +380,12 @@ class ScrollbarComponent extends Component { this._destroyDragHelper(); this._toggleLifecycleListeners('off'); } + + resolveDuplicatedEntityReferenceProperties(oldScrollbar, duplicatedIdsMap) { + if (oldScrollbar.handleEntity) { + this.handleEntity = duplicatedIdsMap[oldScrollbar.handleEntity.getGuid()]; + } + } } export { ScrollbarComponent }; diff --git a/src/framework/components/scrollbar/data.js b/src/framework/components/scrollbar/data.js index 243f76fde4d..18201701c33 100644 --- a/src/framework/components/scrollbar/data.js +++ b/src/framework/components/scrollbar/data.js @@ -11,7 +11,7 @@ class ScrollbarComponentData { handleSize; /** @type {import('../../../framework/entity').Entity} */ - handleEntity; + handleEntity = null; } export { ScrollbarComponentData }; diff --git a/src/framework/components/scrollbar/system.js b/src/framework/components/scrollbar/system.js index ec24224ca1d..c4e3fe0a9cc 100644 --- a/src/framework/components/scrollbar/system.js +++ b/src/framework/components/scrollbar/system.js @@ -7,8 +7,7 @@ const _schema = [ { name: 'enabled', type: 'boolean' }, { name: 'orientation', type: 'number' }, { name: 'value', type: 'number' }, - { name: 'handleSize', type: 'number' }, - { name: 'handleEntity', type: 'entity' } + { name: 'handleSize', type: 'number' } ]; /** @@ -37,6 +36,7 @@ class ScrollbarComponentSystem extends ComponentSystem { } initializeComponentData(component, data, properties) { + component.handleEntity = data.handleEntity; super.initializeComponentData(component, data, _schema); } diff --git a/src/framework/entity.js b/src/framework/entity.js index 3b4df14c2ac..3d2fa554075 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -685,6 +685,11 @@ function resolveDuplicatedEntityReferenceProperties(oldSubtreeRoot, oldEntity, n newEntity.button.resolveDuplicatedEntityReferenceProperties(components.button, duplicatedIdsMap); } + // Handle entity scrollbar attributes + if (components.scrollbar) { + newEntity.scrollbar.resolveDuplicatedEntityReferenceProperties(components.scrollbar, duplicatedIdsMap); + } + // Handle entity anim attributes if (components.anim) { newEntity.anim.resolveDuplicatedEntityReferenceProperties(components.anim, duplicatedIdsMap); From 1ef8a266d25fec43e7f791d2279b69047a645064 Mon Sep 17 00:00:00 2001 From: mrmaxm Date: Tue, 17 Dec 2024 16:27:06 +0200 Subject: [PATCH 9/9] remove EntityReference, optimize ScrollVeiw and Scrollbar --- .../components/scroll-view/component.js | 618 ++++++++++++++---- src/framework/components/scroll-view/data.js | 24 +- .../components/scroll-view/system.js | 11 +- src/framework/components/scrollbar/data.js | 2 +- src/framework/components/scrollbar/system.js | 7 +- src/framework/entity.js | 5 + src/framework/utils/entity-reference.js | 422 ------------ src/index.js | 1 - .../framework/utils/entity-reference.test.mjs | 440 ------------- 9 files changed, 511 insertions(+), 1019 deletions(-) delete mode 100644 src/framework/utils/entity-reference.js delete mode 100644 test/framework/utils/entity-reference.test.mjs diff --git a/src/framework/components/scroll-view/component.js b/src/framework/components/scroll-view/component.js index fbb77cd3392..9cecbe3dd0e 100644 --- a/src/framework/components/scroll-view/component.js +++ b/src/framework/components/scroll-view/component.js @@ -6,7 +6,7 @@ import { Vec3 } from '../../../core/math/vec3.js'; import { ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL } from '../../../scene/constants.js'; -import { EntityReference } from '../../utils/entity-reference.js'; +import { GraphNode } from '../../../scene/graph-node.js'; import { ElementDragHelper } from '../element/element-drag-helper.js'; @@ -14,6 +14,11 @@ import { SCROLL_MODE_BOUNCE, SCROLL_MODE_CLAMP, SCROLL_MODE_INFINITE, SCROLLBAR_ import { Component } from '../component.js'; import { EVENT_MOUSEWHEEL } from '../../../platform/input/constants.js'; +/** + * @import { EventHandle } from '../../../core/event-handle.js' + * @import { Entity } from '../../entity.js' + */ + const _tempScrollValue = new Vec2(); /** @@ -36,38 +41,115 @@ class ScrollViewComponent extends Component { */ static EVENT_SETSCROLL = 'set:scroll'; + /** + * @type {Entity|null} + * @private + */ + _viewportEntity = null; + + /** + * @type {Entity|null} + * @private + */ + _contentEntity = null; + + /** + * @type {Entity|null} + * @private + */ + _horizontalScrollbarEntity = null; + + /** + * @type {Entity|null} + * @private + */ + _verticalScrollbarEntity = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtElementRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtViewportElementRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtViewportResize = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtContentEntityElementAdd = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtContentElementRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtContentResize = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHorizontalScrollbarAdd = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHorizontalScrollbarRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHorizontalScrollbarValue = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtVerticalScrollbarAdd = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtVerticalScrollbarRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtVerticalScrollbarValue = null; + /** * Create a new ScrollViewComponent. * * @param {import('./system.js').ScrollViewComponentSystem} system - The ComponentSystem that * created this Component. - * @param {import('../../entity.js').Entity} entity - The Entity that this Component is - * attached to. + * @param {Entity} entity - The Entity that this Component is attached to. */ constructor(system, entity) { super(system, entity); - this._viewportReference = new EntityReference(this, 'viewportEntity', { - 'element#gain': this._onViewportElementGain, - 'element#resize': this._onSetContentOrViewportSize - }); - - this._contentReference = new EntityReference(this, 'contentEntity', { - 'element#gain': this._onContentElementGain, - 'element#lose': this._onContentElementLose, - 'element#resize': this._onSetContentOrViewportSize - }); - this._scrollbarUpdateFlags = {}; - this._scrollbarReferences = {}; - this._scrollbarReferences[ORIENTATION_HORIZONTAL] = new EntityReference(this, 'horizontalScrollbarEntity', { - 'scrollbar#set:value': this._onSetHorizontalScrollbarValue, - 'scrollbar#gain': this._onHorizontalScrollbarGain - }); - this._scrollbarReferences[ORIENTATION_VERTICAL] = new EntityReference(this, 'verticalScrollbarEntity', { - 'scrollbar#set:value': this._onSetVerticalScrollbarValue, - 'scrollbar#gain': this._onVerticalScrollbarGain - }); + + this._scrollbarEntities = {}; this._prevContentSizes = {}; this._prevContentSizes[ORIENTATION_HORIZONTAL] = null; @@ -80,7 +162,7 @@ class ScrollViewComponent extends Component { this._disabledContentInput = false; this._disabledContentInputEntities = []; - this._toggleLifecycleListeners('on', system); + this._toggleLifecycleListeners('on'); this._toggleElementListeners('on'); } @@ -301,76 +383,196 @@ class ScrollViewComponent extends Component { * Sets the entity to be used as the masked viewport area, within which the content will scroll. * This entity must have an ElementGroup component. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|string|null} */ set viewportEntity(arg) { - this._setValue('viewportEntity', arg); + if (this._viewportEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._viewportEntity && isString && this._viewportEntity.getGuid() === arg) { + return; + } + + if (this._viewportEntity) { + this._viewportEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._viewportEntity = arg; + } else if (isString) { + this._viewportEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._viewportEntity = null; + } + + if (this._viewportEntity) { + this._viewportEntitySubscribe(); + } + + if (this._viewportEntity) { + this.data.viewportEntity = this._viewportEntity.getGuid(); + } else if (isString && arg) { + this.data.viewportEntity = arg; + } } /** * Gets the entity to be used as the masked viewport area, within which the content will scroll. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|null} */ get viewportEntity() { - return this.data.viewportEntity; + return this._viewportEntity; } /** * Sets the entity which contains the scrolling content itself. This entity must have an * {@link ElementComponent}. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|string|null} */ set contentEntity(arg) { - this._setValue('contentEntity', arg); + if (this._contentEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._contentEntity && isString && this._contentEntity.getGuid() === arg) { + return; + } + + if (this._contentEntity) { + this._contentEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._contentEntity = arg; + } else if (isString) { + this._contentEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._contentEntity = null; + } + + if (this._contentEntity) { + this._contentEntitySubscribe(); + } + + if (this._contentEntity) { + this.data.contentEntity = this._contentEntity.getGuid(); + } else if (isString && arg) { + this.data.contentEntity = arg; + } } /** * Gets the entity which contains the scrolling content itself. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|null} */ get contentEntity() { - return this.data.contentEntity; + return this._contentEntity; } /** * Sets the entity to be used as the horizontal scrollbar. This entity must have a * {@link ScrollbarComponent}. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|string|null} */ set horizontalScrollbarEntity(arg) { - this._setValue('horizontalScrollbarEntity', arg); + if (this._horizontalScrollbarEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._horizontalScrollbarEntity && isString && this._horizontalScrollbarEntity.getGuid() === arg) { + return; + } + + if (this._horizontalScrollbarEntity) { + this._horizontalScrollbarEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._horizontalScrollbarEntity = arg; + } else if (isString) { + this._horizontalScrollbarEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._horizontalScrollbarEntity = null; + } + + this._scrollbarEntities[ORIENTATION_HORIZONTAL] = this._horizontalScrollbarEntity; + + if (this._horizontalScrollbarEntity) { + this._horizontalScrollbarEntitySubscribe(); + } + + if (this._horizontalScrollbarEntity) { + this.data.horizontalScrollbarEntity = this._horizontalScrollbarEntity.getGuid(); + } else if (isString && arg) { + this.data.horizontalScrollbarEntity = arg; + } } /** * Gets the entity to be used as the horizontal scrollbar. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|null} */ get horizontalScrollbarEntity() { - return this.data.horizontalScrollbarEntity; + return this._horizontalScrollbarEntity; } /** * Sets the entity to be used as the vertical scrollbar. This entity must have a * {@link ScrollbarComponent}. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|string|null} */ set verticalScrollbarEntity(arg) { - this._setValue('verticalScrollbarEntity', arg); + if (this._verticalScrollbarEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._verticalScrollbarEntity && isString && this._verticalScrollbarEntity.getGuid() === arg) { + return; + } + + if (this._verticalScrollbarEntity) { + this._verticalScrollbarEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._verticalScrollbarEntity = arg; + } else if (isString) { + this._verticalScrollbarEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._verticalScrollbarEntity = null; + } + + this._scrollbarEntities[ORIENTATION_VERTICAL] = this._verticalScrollbarEntity; + + if (this._verticalScrollbarEntity) { + this._verticalScrollbarEntitySubscribe(); + } + + if (this._verticalScrollbarEntity) { + this.data.verticalScrollbarEntity = this._verticalScrollbarEntity.getGuid(); + } else if (isString && arg) { + this.data.verticalScrollbarEntity = arg; + } } /** * Gets the entity to be used as the vertical scrollbar. * - * @type {import('../../../framework/entity.js').Entity} + * @type {Entity|null} */ get verticalScrollbarEntity() { - return this.data.verticalScrollbarEntity; + return this._verticalScrollbarEntity; } /** @@ -405,12 +607,11 @@ class ScrollViewComponent extends Component { * created this Component. * @private */ - _toggleLifecycleListeners(onOrOff, system) { + _toggleLifecycleListeners(onOrOff) { this[onOrOff]('set_horizontal', this._onSetHorizontalScrollingEnabled, this); this[onOrOff]('set_vertical', this._onSetVerticalScrollingEnabled, this); - system.app.systems.element[onOrOff]('add', this._onElementComponentAdd, this); - system.app.systems.element[onOrOff]('beforeremove', this._onElementComponentRemove, this); + this.entity[onOrOff]('element:add', this._onElementComponentAdd, this); } /** @@ -423,7 +624,7 @@ class ScrollViewComponent extends Component { return; } - this.entity.element[onOrOff]('resize', this._onSetContentOrViewportSize, this); + this.entity.element[onOrOff]('resize', this._syncAll, this); this.entity.element[onOrOff](EVENT_MOUSEWHEEL, this._onMouseWheel, this); this._hasElementListeners = onOrOff === 'on'; @@ -431,24 +632,92 @@ class ScrollViewComponent extends Component { } _onElementComponentAdd(entity) { - if (this.entity === entity) { - this._toggleElementListeners('on'); - } + this._evtElementRemove = this.entity.element.once('beforeremove', this._onElementComponentRemove, this); + this._toggleElementListeners('on'); } _onElementComponentRemove(entity) { - if (this.entity === entity) { - this._toggleElementListeners('off'); + this._evtElementRemove?.off(); + this._evtElementRemove = null; + this._toggleElementListeners('off'); + } + + _viewportEntitySubscribe() { + this._evtViewportEntityElementAdd = this._viewportEntity.on('element:add', this._onViewportElementGain, this); + + if (this._viewportEntity.element) { + this._onViewportElementGain(); + } + } + + _viewportEntityUnsubscribe() { + this._evtViewportEntityElementAdd?.off(); + this._evtViewportEntityElementAdd = null; + + if (this._viewportEntity?.element) { + this._onViewportElementLose(); } } + _viewportEntityElementSubscribe() { + const element = this._viewportEntity.element; + this._evtViewportElementRemove = element.once('beforeremove', this._onViewportElementLose, this); + this._evtViewportResize = element.on('resize', this._syncAll, this); + } + + _viewportEntityElementUnsubscribe() { + this._evtViewportElementRemove?.off(); + this._evtViewportElementRemove = null; + + this._evtViewportResize?.off(); + this._evtViewportResize = null; + } + _onViewportElementGain() { + this._viewportEntityElementSubscribe(); this._syncAll(); } + _onViewportElementLose() { + this._viewportEntityElementUnsubscribe(); + } + + _contentEntitySubscribe() { + this._evtContentEntityElementAdd = this._contentEntity.on('element:add', this._onContentElementGain, this); + + if (this._contentEntity.element) { + this._onContentElementGain(); + } + } + + _contentEntityUnsubscribe() { + this._evtContentEntityElementAdd?.off(); + this._evtContentEntityElementAdd = null; + + if (this._contentEntity?.element) { + this._onContentElementLose(); + } + } + + _contentEntityElementSubscribe() { + const element = this._contentEntity.element; + this._evtContentElementRemove = element.once('beforeremove', this._onContentElementLose, this); + this._evtContentResize = element.on('resize', this._syncAll, this); + } + + _contentEntityElementUnsubscribe() { + this._evtContentElementRemove?.off(); + this._evtContentElementRemove = null; + + this._evtContentResize?.off(); + this._evtContentResize = null; + } + _onContentElementGain() { + this._contentEntityElementSubscribe(); this._destroyDragHelper(); - this._contentDragHelper = new ElementDragHelper(this._contentReference.entity.element); + + this._contentDragHelper = new ElementDragHelper(this._contentEntity.element); this._contentDragHelper.on('drag:start', this._onContentDragStart, this); this._contentDragHelper.on('drag:end', this._onContentDragEnd, this); this._contentDragHelper.on('drag:move', this._onContentDragMove, this); @@ -460,12 +729,13 @@ class ScrollViewComponent extends Component { } _onContentElementLose() { + this._contentEntityElementUnsubscribe(); this._destroyDragHelper(); } _onContentDragStart() { - if (this._contentReference.entity && this.enabled && this.entity.enabled) { - this._dragStartPosition.copy(this._contentReference.entity.getLocalPosition()); + if (this._contentEntity && this.enabled && this.entity.enabled) { + this._dragStartPosition.copy(this._contentEntity.getLocalPosition()); } } @@ -475,7 +745,7 @@ class ScrollViewComponent extends Component { } _onContentDragMove(position) { - if (this._contentReference.entity && this.enabled && this.entity.enabled) { + if (this._contentEntity && this.enabled && this.entity.enabled) { this._wasDragged = true; this._setScrollFromContentPosition(position); this._setVelocityFromContentPositionDelta(position); @@ -494,8 +764,38 @@ class ScrollViewComponent extends Component { } } - _onSetContentOrViewportSize() { - this._syncAll(); + _horizontalScrollbarEntitySubscribe() { + this._evtHorizontalScrollbarAdd = this._horizontalScrollbarEntity.on('scrollbar:add', this._onHorizontalScrollbarGain, this); + + if (this._horizontalScrollbarEntity.scrollbar) { + this._onHorizontalScrollbarGain(); + } + } + + _verticalScrollbarEntitySubscribe() { + this._evtVerticalScrollbarAdd = this._verticalScrollbarEntity.on('scrollbar:add', this._onVerticalScrollbarGain, this); + + if (this._verticalScrollbarEntity.scrollbar) { + this._onVerticalScrollbarGain(); + } + } + + _horizontalScrollbarEntityUnsubscribe() { + this._evtHorizontalScrollbarAdd?.off(); + this._evtHorizontalScrollbarAdd = null; + + if (this._horizontalScrollbarEntity.scrollbar) { + this._onHorizontalScrollbarLose(); + } + } + + _verticalScrollbarEntityUnsubscribe() { + this._evtVerticalScrollbarAdd?.off(); + this._evtVerticalScrollbarAdd = null; + + if (this._verticalScrollbarEntity.scrollbar) { + this._onVerticalScrollbarLose(); + } } _onSetHorizontalScrollbarValue(scrollValueX) { @@ -510,22 +810,46 @@ class ScrollViewComponent extends Component { } } - _onSetHorizontalScrollingEnabled() { + _onHorizontalScrollbarGain() { + const scrollbar = this._horizontalScrollbarEntity?.scrollbar; + this._evtHorizontalScrollbarRemove = scrollbar.on('beforeremove', this._onHorizontalScrollbarLose, this); + this._evtHorizontalScrollbarValue = scrollbar.on('set:value', this._onSetHorizontalScrollbarValue, this); + this._syncScrollbarEnabledState(ORIENTATION_HORIZONTAL); + this._syncScrollbarPosition(ORIENTATION_HORIZONTAL); } - _onSetVerticalScrollingEnabled() { + _onVerticalScrollbarGain() { + const scrollbar = this._verticalScrollbarEntity?.scrollbar; + this._evtVerticalScrollbarRemove = scrollbar.on('beforeremove', this._onVerticalScrollbarLose, this); + this._evtVerticalScrollbarValue = scrollbar.on('set:value', this._onSetVerticalScrollbarValue, this); + this._syncScrollbarEnabledState(ORIENTATION_VERTICAL); + this._syncScrollbarPosition(ORIENTATION_VERTICAL); } - _onHorizontalScrollbarGain() { + _onHorizontalScrollbarLose() { + this._evtHorizontalScrollbarRemove?.off(); + this._evtHorizontalScrollbarRemove = null; + + this._evtHorizontalScrollbarValue?.off(); + this._evtHorizontalScrollbarValue = null; + } + + _onVerticalScrollbarLose() { + this._evtVerticalScrollbarRemove?.off(); + this._evtVerticalScrollbarRemove = null; + + this._evtVerticalScrollbarValue?.off(); + this._evtVerticalScrollbarValue = null; + } + + _onSetHorizontalScrollingEnabled() { this._syncScrollbarEnabledState(ORIENTATION_HORIZONTAL); - this._syncScrollbarPosition(ORIENTATION_HORIZONTAL); } - _onVerticalScrollbarGain() { + _onSetVerticalScrollingEnabled() { this._syncScrollbarEnabledState(ORIENTATION_VERTICAL); - this._syncScrollbarPosition(ORIENTATION_VERTICAL); } _onSetScroll(x, y, resetVelocity) { @@ -591,75 +915,79 @@ class ScrollViewComponent extends Component { } _syncContentPosition(orientation) { + if (!this._contentEntity) { + return; + } + const axis = this._getAxis(orientation); const sign = this._getSign(orientation); - const contentEntity = this._contentReference.entity; - - if (contentEntity) { - const prevContentSize = this._prevContentSizes[orientation]; - const currContentSize = this._getContentSize(orientation); - - // If the content size has changed, adjust the scroll value so that the content will - // stay in the same place from the user's perspective. - if (prevContentSize !== null && Math.abs(prevContentSize - currContentSize) > 1e-4) { - const prevMaxOffset = this._getMaxOffset(orientation, prevContentSize); - const currMaxOffset = this._getMaxOffset(orientation, currContentSize); - if (currMaxOffset === 0) { - this._scroll[axis] = 1; - } else { - this._scroll[axis] = math.clamp((this._scroll[axis] * prevMaxOffset) / currMaxOffset, 0, 1); - } + + const prevContentSize = this._prevContentSizes[orientation]; + const currContentSize = this._getContentSize(orientation); + + // If the content size has changed, adjust the scroll value so that the content will + // stay in the same place from the user's perspective. + if (prevContentSize !== null && Math.abs(prevContentSize - currContentSize) > 1e-4) { + const prevMaxOffset = this._getMaxOffset(orientation, prevContentSize); + const currMaxOffset = this._getMaxOffset(orientation, currContentSize); + if (currMaxOffset === 0) { + this._scroll[axis] = 1; + } else { + this._scroll[axis] = math.clamp((this._scroll[axis] * prevMaxOffset) / currMaxOffset, 0, 1); } + } - const offset = this._scroll[axis] * this._getMaxOffset(orientation); - const contentPosition = contentEntity.getLocalPosition(); - contentPosition[axis] = offset * sign; + const offset = this._scroll[axis] * this._getMaxOffset(orientation); + const contentPosition = this._contentEntity.getLocalPosition(); + contentPosition[axis] = offset * sign; - contentEntity.setLocalPosition(contentPosition); + this._contentEntity.setLocalPosition(contentPosition); - this._prevContentSizes[orientation] = currContentSize; - } + this._prevContentSizes[orientation] = currContentSize; } _syncScrollbarPosition(orientation) { + const scrollbarEntity = this._scrollbarEntities[orientation]; + if (!scrollbarEntity?.scrollbar) { + return; + } + const axis = this._getAxis(orientation); - const scrollbarEntity = this._scrollbarReferences[orientation].entity; - if (scrollbarEntity && scrollbarEntity.scrollbar) { - // Setting the value of the scrollbar will fire a 'set:value' event, which in turn - // will call the _onSetHorizontalScrollbarValue/_onSetVerticalScrollbarValue handlers - // and cause a cycle. To avoid this we keep track of the fact that we're in the process - // of updating the scrollbar value. - this._scrollbarUpdateFlags[orientation] = true; - scrollbarEntity.scrollbar.value = this._scroll[axis]; - scrollbarEntity.scrollbar.handleSize = this._getScrollbarHandleSize(axis, orientation); - this._scrollbarUpdateFlags[orientation] = false; - } + // Setting the value of the scrollbar will fire a 'set:value' event, which in turn + // will call the _onSetHorizontalScrollbarValue/_onSetVerticalScrollbarValue handlers + // and cause a cycle. To avoid this we keep track of the fact that we're in the process + // of updating the scrollbar value. + this._scrollbarUpdateFlags[orientation] = true; + scrollbarEntity.scrollbar.value = this._scroll[axis]; + scrollbarEntity.scrollbar.handleSize = this._getScrollbarHandleSize(axis, orientation); + this._scrollbarUpdateFlags[orientation] = false; } // Toggles the scrollbar entities themselves to be enabled/disabled based // on whether the user has enabled horizontal/vertical scrolling on the // scroll view. _syncScrollbarEnabledState(orientation) { - const entity = this._scrollbarReferences[orientation].entity; + const entity = this._scrollbarEntities[orientation]; + if (!entity) { + return; + } - if (entity) { - const isScrollingEnabled = this._getScrollingEnabled(orientation); - const requestedVisibility = this._getScrollbarVisibility(orientation); + const isScrollingEnabled = this._getScrollingEnabled(orientation); + const requestedVisibility = this._getScrollbarVisibility(orientation); - switch (requestedVisibility) { - case SCROLLBAR_VISIBILITY_SHOW_ALWAYS: - entity.enabled = isScrollingEnabled; - return; + switch (requestedVisibility) { + case SCROLLBAR_VISIBILITY_SHOW_ALWAYS: + entity.enabled = isScrollingEnabled; + return; - case SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED: - entity.enabled = isScrollingEnabled && this._contentIsLargerThanViewport(orientation); - return; + case SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED: + entity.enabled = isScrollingEnabled && this._contentIsLargerThanViewport(orientation); + return; - default: - console.warn(`Unhandled scrollbar visibility:${requestedVisibility}`); - entity.enabled = isScrollingEnabled; - } + default: + console.warn(`Unhandled scrollbar visibility:${requestedVisibility}`); + entity.enabled = isScrollingEnabled; } } @@ -722,16 +1050,16 @@ class ScrollViewComponent extends Component { } _getViewportSize(orientation) { - return this._getSize(orientation, this._viewportReference); + return this._getSize(orientation, this._viewportEntity); } _getContentSize(orientation) { - return this._getSize(orientation, this._contentReference); + return this._getSize(orientation, this._contentEntity); } - _getSize(orientation, entityReference) { - if (entityReference.entity && entityReference.entity.element) { - return entityReference.entity.element[this._getCalculatedDimension(orientation)]; + _getSize(orientation, entity) { + if (entity?.element) { + return entity.element[this._getCalculatedDimension(orientation)]; } return 0; @@ -778,7 +1106,7 @@ class ScrollViewComponent extends Component { } onUpdate() { - if (this._contentReference.entity) { + if (this._contentEntity) { this._updateVelocity(); this._syncScrollbarEnabledState(ORIENTATION_HORIZONTAL); this._syncScrollbarEnabledState(ORIENTATION_VERTICAL); @@ -798,10 +1126,10 @@ class ScrollViewComponent extends Component { } if (Math.abs(this._velocity.x) > 1e-4 || Math.abs(this._velocity.y) > 1e-4) { - const position = this._contentReference.entity.getLocalPosition(); + const position = this._contentEntity.getLocalPosition(); position.x += this._velocity.x; position.y += this._velocity.y; - this._contentReference.entity.setLocalPosition(position); + this._contentEntity.setLocalPosition(position); this._setScrollFromContentPosition(position); } @@ -889,12 +1217,12 @@ class ScrollViewComponent extends Component { } _setScrollbarComponentsEnabled(enabled) { - if (this._scrollbarReferences[ORIENTATION_HORIZONTAL].hasComponent('scrollbar')) { - this._scrollbarReferences[ORIENTATION_HORIZONTAL].entity.scrollbar.enabled = enabled; + if (this._horizontalScrollbarEntity?.scrollbar) { + this._horizontalScrollbarEntity.scrollbar.enabled = enabled; } - if (this._scrollbarReferences[ORIENTATION_VERTICAL].hasComponent('scrollbar')) { - this._scrollbarReferences[ORIENTATION_VERTICAL].entity.scrollbar.enabled = enabled; + if (this._verticalScrollbarEntity?.scrollbar) { + this._verticalScrollbarEntity.scrollbar.enabled = enabled; } } @@ -905,19 +1233,21 @@ class ScrollViewComponent extends Component { } _onMouseWheel(event) { - if (this.useMouseWheel) { - const wheelEvent = event.event; + if (!this.useMouseWheel || !this._contentEntity?.element) { + return; + } - // wheelEvent's delta variables are screen space, so they need to be normalized first - const normalizedDeltaX = (wheelEvent.deltaX / this._contentReference.entity.element.calculatedWidth) * this.mouseWheelSensitivity.x; - const normalizedDeltaY = (wheelEvent.deltaY / this._contentReference.entity.element.calculatedHeight) * this.mouseWheelSensitivity.y; + const wheelEvent = event.event; - // update scroll positions, clamping to [0, maxScrollValue] to always prevent over-shooting - const scrollX = math.clamp(this._scroll.x + normalizedDeltaX, 0, this._getMaxScrollValue(ORIENTATION_HORIZONTAL)); - const scrollY = math.clamp(this._scroll.y + normalizedDeltaY, 0, this._getMaxScrollValue(ORIENTATION_VERTICAL)); + // wheelEvent's delta variables are screen space, so they need to be normalized first + const normalizedDeltaX = (wheelEvent.deltaX / this._contentEntity.element.calculatedWidth) * this.mouseWheelSensitivity.x; + const normalizedDeltaY = (wheelEvent.deltaY / this._contentEntity.element.calculatedHeight) * this.mouseWheelSensitivity.y; - this.scroll = new Vec2(scrollX, scrollY); - } + // update scroll positions, clamping to [0, maxScrollValue] to always prevent over-shooting + const scrollX = math.clamp(this._scroll.x + normalizedDeltaX, 0, this._getMaxScrollValue(ORIENTATION_HORIZONTAL)); + const scrollY = math.clamp(this._scroll.y + normalizedDeltaY, 0, this._getMaxScrollValue(ORIENTATION_VERTICAL)); + + this.scroll = new Vec2(scrollX, scrollY); } // re-enable useInput flag on any descendant that was disabled @@ -946,10 +1276,9 @@ class ScrollViewComponent extends Component { } }; - const contentEntity = this._contentReference.entity; - if (contentEntity) { + if (this._contentEntity) { // disable input recursively for all children of the content entity - const children = contentEntity.children; + const children = this._contentEntity.children; for (let i = 0, l = children.length; i < l; i++) { _disableInput(children[i]); } @@ -959,10 +1288,6 @@ class ScrollViewComponent extends Component { } onEnable() { - this._viewportReference.onParentComponentEnable(); - this._contentReference.onParentComponentEnable(); - this._scrollbarReferences[ORIENTATION_HORIZONTAL].onParentComponentEnable(); - this._scrollbarReferences[ORIENTATION_VERTICAL].onParentComponentEnable(); this._setScrollbarComponentsEnabled(true); this._setContentDraggingEnabled(true); @@ -975,10 +1300,25 @@ class ScrollViewComponent extends Component { } onRemove() { - this._toggleLifecycleListeners('off', this.system); + this._toggleLifecycleListeners('off'); this._toggleElementListeners('off'); this._destroyDragHelper(); } + + resolveDuplicatedEntityReferenceProperties(oldScrollView, duplicatedIdsMap) { + if (oldScrollView.viewportEntity) { + this.viewportEntity = duplicatedIdsMap[oldScrollView.viewportEntity.getGuid()]; + } + if (oldScrollView.contentEntity) { + this.contentEntity = duplicatedIdsMap[oldScrollView.contentEntity.getGuid()]; + } + if (oldScrollView.horizontalScrollbarEntity) { + this.horizontalScrollbarEntity = duplicatedIdsMap[oldScrollView.horizontalScrollbarEntity.getGuid()]; + } + if (oldScrollView.verticalScrollbarEntity) { + this.verticalScrollbarEntity = duplicatedIdsMap[oldScrollView.verticalScrollbarEntity.getGuid()]; + } + } } export { ScrollViewComponent }; diff --git a/src/framework/components/scroll-view/data.js b/src/framework/components/scroll-view/data.js index d546a75f25d..408a7a18043 100644 --- a/src/framework/components/scroll-view/data.js +++ b/src/framework/components/scroll-view/data.js @@ -1,5 +1,9 @@ import { Vec2 } from '../../../core/math/vec2.js'; +/** + * @import { Entity } from '../../../framework/entity.js' + */ + const DEFAULT_DRAG_THRESHOLD = 10; class ScrollViewComponentData { @@ -27,22 +31,22 @@ class ScrollViewComponentData { mouseWheelSensitivity = new Vec2(1, 1); /** @type {number} */ - horizontalScrollbarVisibility; + horizontalScrollbarVisibility = 0; /** @type {number} */ - verticalScrollbarVisibility; + verticalScrollbarVisibility = 0; - /** @type {import('../../../framework/entity.js').Entity} */ - viewportEntity; + /** @type {Entity|null} */ + viewportEntity = null; - /** @type {import('../../../framework/entity.js').Entity} */ - contentEntity; + /** @type {Entity|null} */ + contentEntity = null; - /** @type {import('../../../framework/entity.js').Entity} */ - horizontalScrollbarEntity; + /** @type {Entity|null} */ + horizontalScrollbarEntity = null; - /** @type {import('../../../framework/entity.js').Entity} */ - verticalScrollbarEntity; + /** @type {Entity|null} */ + verticalScrollbarEntity = null; } export { ScrollViewComponentData }; diff --git a/src/framework/components/scroll-view/system.js b/src/framework/components/scroll-view/system.js index d02b5bede21..bd23a3611d2 100644 --- a/src/framework/components/scroll-view/system.js +++ b/src/framework/components/scroll-view/system.js @@ -16,11 +16,7 @@ const _schema = [ { name: 'useMouseWheel', type: 'boolean' }, { name: 'mouseWheelSensitivity', type: 'vec2' }, { name: 'horizontalScrollbarVisibility', type: 'number' }, - { name: 'verticalScrollbarVisibility', type: 'number' }, - { name: 'viewportEntity', type: 'entity' }, - { name: 'contentEntity', type: 'entity' }, - { name: 'horizontalScrollbarEntity', type: 'entity' }, - { name: 'verticalScrollbarEntity', type: 'entity' } + { name: 'verticalScrollbarVisibility', type: 'number' } ]; const DEFAULT_DRAG_THRESHOLD = 10; @@ -64,6 +60,11 @@ class ScrollViewComponentSystem extends ComponentSystem { } super.initializeComponentData(component, data, _schema); + + component.viewportEntity = data.viewportEntity; + component.contentEntity = data.contentEntity; + component.horizontalScrollbarEntity = data.horizontalScrollbarEntity; + component.verticalScrollbarEntity = data.verticalScrollbarEntity; } onUpdate(dt) { diff --git a/src/framework/components/scrollbar/data.js b/src/framework/components/scrollbar/data.js index 18201701c33..4f12940233f 100644 --- a/src/framework/components/scrollbar/data.js +++ b/src/framework/components/scrollbar/data.js @@ -8,7 +8,7 @@ class ScrollbarComponentData { value = 0; /** @type {number} */ - handleSize; + handleSize = 0; /** @type {import('../../../framework/entity').Entity} */ handleEntity = null; diff --git a/src/framework/components/scrollbar/system.js b/src/framework/components/scrollbar/system.js index c4e3fe0a9cc..d928647fb97 100644 --- a/src/framework/components/scrollbar/system.js +++ b/src/framework/components/scrollbar/system.js @@ -32,12 +32,17 @@ class ScrollbarComponentSystem extends ComponentSystem { this.schema = _schema; + this.on('add', this._onAddComponent, this); this.on('beforeremove', this._onRemoveComponent, this); } initializeComponentData(component, data, properties) { - component.handleEntity = data.handleEntity; super.initializeComponentData(component, data, _schema); + component.handleEntity = data.handleEntity; + } + + _onAddComponent(entity) { + entity.fire('scrollbar:add'); } _onRemoveComponent(entity, component) { diff --git a/src/framework/entity.js b/src/framework/entity.js index 3d2fa554075..7c3e8719968 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -685,6 +685,11 @@ function resolveDuplicatedEntityReferenceProperties(oldSubtreeRoot, oldEntity, n newEntity.button.resolveDuplicatedEntityReferenceProperties(components.button, duplicatedIdsMap); } + // Handle entity scrollview attributes + if (components.scrollview) { + newEntity.scrollview.resolveDuplicatedEntityReferenceProperties(components.scrollview, duplicatedIdsMap); + } + // Handle entity scrollbar attributes if (components.scrollbar) { newEntity.scrollbar.resolveDuplicatedEntityReferenceProperties(components.scrollbar, duplicatedIdsMap); diff --git a/src/framework/utils/entity-reference.js b/src/framework/utils/entity-reference.js deleted file mode 100644 index c58a34d4ebf..00000000000 --- a/src/framework/utils/entity-reference.js +++ /dev/null @@ -1,422 +0,0 @@ -import { Component } from '../components/component.js'; -import { Entity } from '../entity.js'; - -import { EventHandler } from '../../core/event-handler.js'; - -/** - * An EntityReference can be used in scenarios where a component has one or more properties that - * refer to entities in the scene graph. Using an EntityReference simplifies the job of dealing - * with the presence or non-presence of the underlying entity and its components, especially when - * it comes to dealing with the runtime addition or removal of components, and addition/removal of - * associated event listeners. - * - * ## Usage Scenario ## - * - * Imagine that you're creating a Checkbox component, which has a reference to an entity - * representing the checkmark/tickmark that is rendered in the Checkbox. The reference is modeled - * as an entity guid property on the Checkbox component, called simply 'checkmark'. We have to - * implement a basic piece of functionality whereby when the 'checkmark' entity reference is set, - * the Checkbox component must toggle the tint of an ImageElementComponent present on the checkmark - * entity to indicate whether the Checkbox is currently in the active or inactive state. - * - * Without using an EntityReference, the Checkbox component must implement some or all of the - * following: - * - * - Listen for its 'checkmark' property being set to a valid guid, and retrieve a reference to the - * entity associated with this guid whenever it changes (i.e. via `app.root.findByGuid()`). - * - Once a valid entity is received, check to see whether it has already has an - * ImageElementComponent or not: - * - If it has one, proceed to set the tint of the ImageElementComponent based on whether the - * Checkbox is currently active or inactive. - * - If it doesn't have one, add a listener to wait for the addition of an ImageElementComponent, - * and then apply the tint once one becomes present. - * - If the checkmark entity is then reassigned (such as if the user reassigns the field in the - * editor, or if this is done at runtime via a script), a well-behaved Checkbox component must - * also undo the tinting so that no lasting effect is applied to the old entity. - * - If the checkmark entity's ImageElementComponent is removed and then another - * ImageElementComponent is added, the Checkbox component must handle this in order to re-apply - * the tint. - * - To prevent memory leaks, the Checkbox component must also make sure to correctly remove - * listeners in each of the following scenarios: - * - Destruction of the Checkbox component. - * - Reassignment of the checkmark entity. - * - Removal of the ImageElementComponent. - * - It must also be careful not to double-add listeners in any of the above code paths, to avoid - * various forms of undesirable behavior. - * - * If the Checkbox component becomes more complicated and has multiple entity reference properties, - * all of the above must be done correctly for each entity. Similarly, if it depends on multiple - * different component types being present on the entities it has references to, it must correctly - * handle the presence and non-presence of each of these components in the various possible - * sequences of addition and removal. In addition to generating a lot of boilerplate, it's also - * very easy for subtle mistakes to be made that lead to memory leaks, null reference errors or - * visual bugs. - * - * By using an EntityReference, all of the above can be reduced to the following: - * - * ```javascript - * function CheckboxComponent() { - * this._checkmarkReference = new pc.EntityReference(this, 'checkmark', { - * 'element#gain': this._onCheckmarkImageElementGain, - * 'element#lose': this._onCheckmarkImageElementLose - * }); - * } - * ``` - * - * Using the above code snippet, the `_onCheckmarkImageElementGain()` listener will be called - * in either of the following scenarios: - * - * 1. A checkmark entity is assigned and already has an ElementComponent. - * 2. A checkmark entity is assigned that does not have an ElementComponent, but one is added - * later. - * - * Similarly, the `_onCheckmarkImageElementLose()` listener will be called in either of the - * following scenarios: - * - * 1. An ElementComponent is removed from the checkmark entity. - * 2. The checkmark entity is re-assigned (i.e. to another entity), or nullified. In this - * scenario the callback will only be called if the entity actually had an ElementComponent. - * - * ## Event String Format ## - * - * The event string (i.e. "element#gain" in the above examples) is of the format - * `sourceName#eventName`, and is defined as follows: - * - * - `sourceName`: May be any component name, or the special string "entity", which refers to the - * entity itself. - * - `eventName`: May be the name of any event dispatched by the relevant component or entity, as - * well as the special strings "gain" or "lose". - * - * Some examples are as follows: - * - * ```javascript - * "entity#destroy" // Called when the entity managed by the entity reference is destroyed. - * "element#set:width" // Called when the width of an ElementComponent is set. - * ``` - * - * When the entity reference changes to another entity (or null) the set:entity event is fired. - * - * ## Ownership and Destruction ## - * - * The lifetime of an ElementReference is tied to the parent component that instantiated it. This - * coupling is indicated by the provision of the `this` keyword to the ElementReference's - * constructor in the above examples (i.e. `new pc.EntityReference(this, ...`). - * - * Any event listeners managed by the ElementReference are automatically cleaned up when the parent - * component is removed or the parent component's entity is destroyed – as such you should never - * have to worry about dangling listeners. - * - * Additionally, any callbacks listed in the event config will automatically be called in the scope - * of the parent component – you should never have to worry about manually calling - * `Function.bind()`. - * - * @ignore - */ -class EntityReference extends EventHandler { - /** - * Helper class used for managing component properties that represent entity references. - * - * @param {Component} parentComponent - A reference to the parent component that owns this - * entity reference. - * @param {string} entityPropertyName - The name of the component property that contains the - * entity guid. - * @param {Object} [eventConfig] - A map of event listener configurations. - */ - constructor(parentComponent, entityPropertyName, eventConfig) { - super(); - - if (!parentComponent || !(parentComponent instanceof Component)) { - throw new Error('The parentComponent argument is required and must be a Component'); - } else if (!entityPropertyName || typeof entityPropertyName !== 'string') { - throw new Error('The propertyName argument is required and must be a string'); - } else if (eventConfig && typeof eventConfig !== 'object') { - throw new Error('If provided, the eventConfig argument must be an object'); - } - - this._parentComponent = parentComponent; - this._entityPropertyName = entityPropertyName; - this._entity = null; - this._app = parentComponent.system.app; - - this._configureEventListeners(eventConfig || {}, { - 'entity#destroy': this._onEntityDestroy - }); - this._toggleLifecycleListeners('on'); - } - - _configureEventListeners(externalEventConfig, internalEventConfig) { - const externalEventListenerConfigs = this._parseEventListenerConfig(externalEventConfig, 'external', this._parentComponent); - const internalEventListenerConfigs = this._parseEventListenerConfig(internalEventConfig, 'internal', this); - - this._eventListenerConfigs = externalEventListenerConfigs.concat(internalEventListenerConfigs); - this._listenerStatusFlags = {}; - this._gainListeners = {}; - this._loseListeners = {}; - } - - _parseEventListenerConfig(eventConfig, prefix, scope) { - return Object.keys(eventConfig).map((listenerDescription, index) => { - const listenerDescriptionParts = listenerDescription.split('#'); - const sourceName = listenerDescriptionParts[0]; - const eventName = listenerDescriptionParts[1]; - const callback = eventConfig[listenerDescription]; - - if (listenerDescriptionParts.length !== 2 || - typeof sourceName !== 'string' || sourceName.length === 0 || - typeof eventName !== 'string' || eventName.length === 0) { - throw new Error(`Invalid event listener description: \`${listenerDescription}\``); - } - - if (typeof callback !== 'function') { - throw new Error(`Invalid or missing callback for event listener \`${listenerDescription}\``); - } - - return { - id: `${prefix}_${index}_${listenerDescription}`, - sourceName: sourceName, - eventName: eventName, - callback: callback, - scope: scope - }; - }, this); - } - - _toggleLifecycleListeners(onOrOff) { - this._parentComponent[onOrOff](`set_${this._entityPropertyName}`, this._onSetEntity, this); - this._parentComponent.system[onOrOff]('beforeremove', this._onParentComponentRemove, this); - - this._app.systems[onOrOff]('postPostInitialize', this._updateEntityReference, this); - this._app[onOrOff]('tools:sceneloaded', this._onSceneLoaded, this); - - // For any event listeners that relate to the gain/loss of a component, register - // listeners that will forward the add/remove component events - const allComponentSystems = []; - - for (let i = 0; i < this._eventListenerConfigs.length; ++i) { - const config = this._eventListenerConfigs[i]; - const componentSystem = this._app.systems[config.sourceName]; - - if (componentSystem) { - if (allComponentSystems.indexOf(componentSystem) === -1) { - allComponentSystems.push(componentSystem); - } - - if (componentSystem && config.eventName === 'gain') { - this._gainListeners[config.sourceName] = config; - } - - if (componentSystem && config.eventName === 'lose') { - this._loseListeners[config.sourceName] = config; - } - } - } - - for (let i = 0; i < allComponentSystems.length; ++i) { - allComponentSystems[i][onOrOff]('add', this._onComponentAdd, this); - allComponentSystems[i][onOrOff]('beforeremove', this._onComponentRemove, this); - } - } - - _onSetEntity(name, oldValue, newValue) { - if (newValue instanceof Entity) { - this._updateEntityReference(); - } else { - if (newValue !== null && newValue !== undefined && typeof newValue !== 'string') { - console.warn(`Entity field \`${this._entityPropertyName}\` was set to unexpected type '${typeof newValue}'`); - return; - } - - if (oldValue !== newValue) { - this._updateEntityReference(); - } - } - } - - /** - * Must be called from the parent component's onEnable() method in order for entity references - * to be correctly resolved when {@link Entity#clone} is called. - * - * @private - */ - onParentComponentEnable() { - // When an entity is cloned via the JS API, we won't be able to resolve the - // entity reference until the cloned entity has been added to the scene graph. - // We can detect this by waiting for the parent component to be enabled, in the - // specific case where we haven't yet been able to resolve an entity reference. - if (!this._entity) { - this._updateEntityReference(); - } - } - - // When running within the editor, postInitialize is fired before the scene graph - // has been fully constructed. As such we use the special tools:sceneloaded event - // in order to know when the graph is ready to traverse. - _onSceneLoaded() { - this._updateEntityReference(); - } - - _updateEntityReference() { - let nextEntityGuid = this._parentComponent.data[this._entityPropertyName]; - let nextEntity; - - if (nextEntityGuid instanceof Entity) { - // if value is set to a Entity itself replace value with the GUID - nextEntity = nextEntityGuid; - nextEntityGuid = nextEntity.getGuid(); - this._parentComponent.data[this._entityPropertyName] = nextEntityGuid; - } else { - const root = this._parentComponent.system.app.root; - const isOnSceneGraph = this._parentComponent.entity.isDescendantOf(root); - - nextEntity = (isOnSceneGraph && nextEntityGuid) ? root.findByGuid(nextEntityGuid) : null; - } - - const hasChanged = this._entity !== nextEntity; - - if (hasChanged) { - if (this._entity) { - this._onBeforeEntityChange(); - } - - this._entity = nextEntity; - - if (this._entity) { - this._onAfterEntityChange(); - } - - this.fire('set:entity', this._entity); - } - } - - _onBeforeEntityChange() { - this._toggleEntityListeners('off'); - this._callAllGainOrLoseListeners(this._loseListeners); - } - - _onAfterEntityChange() { - this._toggleEntityListeners('on'); - this._callAllGainOrLoseListeners(this._gainListeners); - } - - _onComponentAdd(entity, component) { - const componentName = component.system.id; - - if (entity === this._entity) { - this._callGainOrLoseListener(componentName, this._gainListeners); - this._toggleComponentListeners('on', componentName); - } - } - - _onComponentRemove(entity, component) { - const componentName = component.system.id; - - if (entity === this._entity) { - this._callGainOrLoseListener(componentName, this._loseListeners); - this._toggleComponentListeners('off', componentName, true); - } - } - - _callAllGainOrLoseListeners(listenerMap) { - for (const componentName in this._entity.c) { - this._callGainOrLoseListener(componentName, listenerMap); - } - } - - _callGainOrLoseListener(componentName, listenerMap) { - if (this._entity.c.hasOwnProperty(componentName) && listenerMap[componentName]) { - const config = listenerMap[componentName]; - config.callback.call(config.scope); - } - } - - _toggleEntityListeners(onOrOff, isDestroying) { - if (this._entity) { - for (let i = 0; i < this._eventListenerConfigs.length; ++i) { - this._safeToggleListener(onOrOff, this._eventListenerConfigs[i], isDestroying); - } - } - } - - _toggleComponentListeners(onOrOff, componentName, isDestroying) { - for (let i = 0; i < this._eventListenerConfigs.length; ++i) { - const config = this._eventListenerConfigs[i]; - - if (config.sourceName === componentName) { - this._safeToggleListener(onOrOff, config, isDestroying); - } - } - } - - _safeToggleListener(onOrOff, config, isDestroying) { - const isAdding = (onOrOff === 'on'); - - // Prevent duplicate listeners - if (isAdding && this._listenerStatusFlags[config.id]) { - return; - } - - const source = this._getEventSource(config.sourceName, isDestroying); - - if (source) { - source[onOrOff](config.eventName, config.callback, config.scope); - this._listenerStatusFlags[config.id] = isAdding; - } - } - - _getEventSource(sourceName, isDestroying) { - // The 'entity' source name is a special case - we just want to return - // a reference to the entity itself. For all other cases the source name - // should refer to a component. - if (sourceName === 'entity') { - return this._entity; - } - - const component = this._entity[sourceName]; - - if (component) { - return component; - } - - if (!isDestroying) { - console.warn(`Entity has no component with name ${sourceName}`); - } - - return null; - } - - _onEntityDestroy(entity) { - if (this._entity === entity) { - this._toggleEntityListeners('off', true); - this._entity = null; - } - } - - _onParentComponentRemove(entity, component) { - if (component === this._parentComponent) { - this._toggleLifecycleListeners('off'); - this._toggleEntityListeners('off', true); - } - } - - /** - * Convenience method indicating whether the entity exists and has a component of the provided - * type. - * - * @param {string} componentName - Name of the component. - * @returns {boolean} True if the entity exists and has a component of the provided type. - */ - hasComponent(componentName) { - return (this._entity && this._entity.c) ? !!this._entity.c[componentName] : false; - } - - /** - * A reference to the entity, if present. - * - * @type {Entity} - */ - get entity() { - return this._entity; - } -} - -export { EntityReference }; diff --git a/src/index.js b/src/index.js index 5caa0be544a..14745067189 100644 --- a/src/index.js +++ b/src/index.js @@ -231,7 +231,6 @@ export { ElementComponent } from './framework/components/element/component.js'; export { ElementComponentSystem } from './framework/components/element/system.js'; export { ElementDragHelper } from './framework/components/element/element-drag-helper.js'; export { Entity } from './framework/entity.js'; -export { EntityReference } from './framework/utils/entity-reference.js'; export { GSplatComponent } from './framework/components/gsplat/component.js'; export { GSplatComponentSystem } from './framework/components/gsplat/system.js'; export { ImageElement } from './framework/components/element/image-element.js'; diff --git a/test/framework/utils/entity-reference.test.mjs b/test/framework/utils/entity-reference.test.mjs deleted file mode 100644 index 3c5b68fff6c..00000000000 --- a/test/framework/utils/entity-reference.test.mjs +++ /dev/null @@ -1,440 +0,0 @@ -import { Application } from '../../../src/framework/application.js'; -import { Entity } from '../../../src/framework/entity.js'; -import { EntityReference } from '../../../src/framework/utils/entity-reference.js'; - -import { DummyComponentSystem } from '../test-component/system.mjs'; - -import { HTMLCanvasElement } from '@playcanvas/canvas-mock'; - -import { expect } from 'chai'; -import { restore, spy, stub } from 'sinon'; - -/** @typedef {import('../../../../src/framework/components/component.js').Component} Component */ - -describe('EntityReference', () => { - /** @type {Application} */ - let app; - /** @type {Entity} */ - let testEntity; - /** @type {Component} */ - let testComponent; - /** @type {Entity} */ - let otherEntity1; - /** @type {Entity} */ - let otherEntity2; - - beforeEach(() => { - const canvas = new HTMLCanvasElement(500, 500); - app = new Application(canvas); - - app.systems.add(new DummyComponentSystem(app)); - - testEntity = new Entity('testEntity', app); - testComponent = testEntity.addComponent('dummy', {}); - - otherEntity1 = new Entity('otherEntity1', app); - otherEntity1.addComponent('dummy', {}); - otherEntity2 = new Entity('otherEntity2', app); - - app.root.addChild(testEntity); - app.root.addChild(otherEntity1); - app.root.addChild(otherEntity2); - }); - - afterEach(() => { - restore(); - app.destroy(); - }); - - // Assertion helpers that rely on checking some private state. Usually I wouldn't do - // this, but given that we're checking such a stable part of the API (_callbacks has - // been present since 2011) I think it's preferable to adding public methods to the - // Events class that are only required for tests. Also it's critical that listener - // addition and removal is implemented correctly by EntityReference in order to avoid - // memory leaks, so the benefits as significant. - function getTotalEventListeners(entity) { - let total = 0; - - for (const callbacks of entity._callbacks.values()) { - total += callbacks.length; - } - - return total; - } - - function getNumListenersForEvent(entity, eventName) { - return entity._callbacks.get(eventName)?.length || 0; - } - - it('provides a reference to the entity once the guid is populated', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - expect(reference.entity).to.equal(null); - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(otherEntity1); - }); - - it('does not attempt to resolve the entity reference if the parent component is not on the scene graph yet', () => { - app.root.removeChild(testEntity); - - spy(app.root, 'findByGuid'); - - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(reference.entity).to.equal(null); - expect(app.root.findByGuid.callCount).to.equal(0); - }); - - it('resolves the entity reference when onParentComponentEnable() is called', () => { - app.root.removeChild(testEntity); - - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(null); - - app.root.addChild(testEntity); - reference.onParentComponentEnable(); - - expect(reference.entity).to.equal(otherEntity1); - }); - - it('nullifies the reference when the guid is nullified', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(otherEntity1); - - testComponent.myEntity1 = null; - expect(reference.entity).to.equal(null); - }); - - it('nullifies the reference when the referenced entity is destroyed', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(otherEntity1); - - otherEntity1.destroy(); - expect(reference.entity).to.equal(null); - }); - - it('removes all entity and component listeners when the guid is reassigned', () => { - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': stub(), - 'dummy#bar': stub() - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(2); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(1); - - testComponent.myEntity1 = otherEntity2.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(0); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(0); - }); - - it('removes all entity and component listeners when the parent component is removed', () => { - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': stub(), - 'dummy#bar': stub() - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(2); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(2); - - testEntity.removeComponent('dummy'); - expect(getTotalEventListeners(otherEntity1)).to.equal(0); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(0); - }); - - it('removes all entity and component listeners when the parent component\'s entity is destroyed', () => { - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': stub(), - 'dummy#bar': stub() - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(2); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(2); - - testEntity.destroy(); - expect(getTotalEventListeners(otherEntity1)).to.equal(0); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(0); - }); - - it('fires component gain events when a guid is first assigned, if the referenced entity already has the component', () => { - const gainListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#gain': gainListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(gainListener.callCount).to.equal(1); - }); - - it('fires component gain events once a component is added', () => { - const gainListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity2', { - 'dummy#gain': gainListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity2 = otherEntity2.getGuid(); - - expect(gainListener.callCount).to.equal(0); - - otherEntity2.addComponent('dummy', {}); - - expect(gainListener.callCount).to.equal(1); - }); - - it('fires component lose and gain events when a component is removed and re-added', () => { - const gainListener = stub(); - const loseListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#gain': gainListener, - 'dummy#lose': loseListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(gainListener.callCount).to.equal(1); - expect(loseListener.callCount).to.equal(0); - - otherEntity1.removeComponent('dummy'); - - expect(gainListener.callCount).to.equal(1); - expect(loseListener.callCount).to.equal(1); - - otherEntity1.addComponent('dummy', {}); - - expect(gainListener.callCount).to.equal(2); - expect(loseListener.callCount).to.equal(1); - }); - - it('fires component lose events when the guid is reassigned, but only for component types that the entity had', () => { - const dummyLoseListener = stub(); - const lightLoseListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#lose': dummyLoseListener, - 'light#lose': lightLoseListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(dummyLoseListener.callCount).to.equal(0); - expect(lightLoseListener.callCount).to.equal(0); - - testComponent.myEntity1 = null; - - expect(dummyLoseListener.callCount).to.equal(1); - expect(lightLoseListener.callCount).to.equal(0); - }); - - it('forwards any events dispatched by a component', () => { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#foo': fooListener, - 'dummy#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - otherEntity1.dummy.fire('foo', 'a', 'b'); - expect(fooListener.callCount).to.equal(1); - expect(fooListener.getCall(0).args[0]).to.equal('a'); - expect(fooListener.getCall(0).args[1]).to.equal('b'); - expect(barListener.callCount).to.equal(0); - - otherEntity1.dummy.fire('bar', 'c', 'd'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - expect(barListener.getCall(0).args[0]).to.equal('c'); - expect(barListener.getCall(0).args[1]).to.equal('d'); - }); - - it('correctly handles component event forwarding across component removal and subsequent re-addition', () => { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#foo': fooListener, - 'dummy#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - const oldDummyComponent = otherEntity1.dummy; - - otherEntity1.removeComponent('dummy'); - - oldDummyComponent.fire('foo'); - oldDummyComponent.fire('bar'); - expect(fooListener.callCount).to.equal(0); - expect(barListener.callCount).to.equal(0); - - const newDummyComponent = otherEntity1.addComponent('dummy'); - - newDummyComponent.fire('foo'); - newDummyComponent.fire('bar'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - }); - - it('forwards any events dispatched by the entity', () => { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': fooListener, - 'entity#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - otherEntity1.fire('foo', 'a', 'b'); - expect(fooListener.callCount).to.equal(1); - expect(fooListener.getCall(0).args[0]).to.equal('a'); - expect(fooListener.getCall(0).args[1]).to.equal('b'); - expect(barListener.callCount).to.equal(0); - - otherEntity1.fire('bar', 'c', 'd'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - expect(barListener.getCall(0).args[0]).to.equal('c'); - expect(barListener.getCall(0).args[1]).to.equal('d'); - }); - - it('correctly handles entity event forwarding across entity nullification and subsequent reassignment', () => { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': fooListener, - 'entity#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - testComponent.myEntity1 = null; - - otherEntity1.fire('foo'); - otherEntity1.fire('bar'); - expect(fooListener.callCount).to.equal(0); - expect(barListener.callCount).to.equal(0); - - testComponent.myEntity1 = otherEntity2.getGuid(); - - otherEntity2.fire('foo'); - otherEntity2.fire('bar'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - }); - - it('validates the event map', () => { - function testEventMap(eventMap) { - const reference = new EntityReference(testComponent, 'myEntity1', eventMap); - expect(reference).to.be.ok; - } - - const callback = stub(); - - expect(() => { - testEventMap({ 'foo': callback }); - }).to.throw('Invalid event listener description: `foo`'); - - expect(() => { - testEventMap({ 'foo#': callback }); - }).to.throw('Invalid event listener description: `foo#`'); - - expect(() => { - testEventMap({ '#foo': callback }); - }).to.throw('Invalid event listener description: `#foo`'); - - expect(() => { - testEventMap({ 'foo#bar': null }); - }).to.throw('Invalid or missing callback for event listener `foo#bar`'); - }); - - it('logs a warning if the entity property is set to anything other than a string, undefined or null', () => { - stub(console, 'warn'); - - const reference = new EntityReference(testComponent, 'myEntity1'); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - testComponent.myEntity1 = null; - testComponent.myEntity1 = undefined; - - expect(console.warn.callCount).to.equal(0); - - testComponent.myEntity1 = {}; - - expect(console.warn.callCount).to.equal(1); - expect(console.warn.getCall(0).args[0]).to.equal('Entity field `myEntity1` was set to unexpected type \'object\''); - }); - - it('set reference to a Entity instead of guid, converts property to guid', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1; - - expect(testComponent.myEntity1).to.equal(otherEntity1.getGuid(), 'Component property converted to guid'); - expect(reference.entity).to.equal(otherEntity1); - }); - - it('set reference to a Entity that is not in hierarchy, converts property to guid', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - const entity = new Entity(); - testComponent.myEntity1 = entity; - - expect(testComponent.myEntity1).to.equal(entity.getGuid(), 'Component property converted to guid'); - expect(reference.entity).to.equal(entity); - }); - - it('hasComponent() returns false if the entity is not present', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - - expect(reference.hasComponent('dummy')).to.equal(false); - }); - - it('hasComponent() returns false if the entity is present but does not have a component of the provided type', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - otherEntity1.removeComponent('dummy'); - - expect(reference.hasComponent('dummy')).to.equal(false); - }); - - it('hasComponent() returns true if the entity is present and has a component of the provided type', () => { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(reference.hasComponent('dummy')).to.equal(true); - }); - -});