diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js index 910c0d80528..634e0fe1407 100644 --- a/src/deprecated/deprecated.js +++ b/src/deprecated/deprecated.js @@ -122,6 +122,7 @@ import { } from '../framework/components/rigid-body/constants.js'; import { RigidBodyComponent } from '../framework/components/rigid-body/component.js'; import { RigidBodyComponentSystem } from '../framework/components/rigid-body/system.js'; +import { ZoneComponent } from '../framework/components/zone/component.js'; import { basisInitialize } from '../framework/handlers/basis.js'; import { LitShader } from '../scene/shader-lib/programs/lit-shader.js'; import { Geometry } from '../scene/geometry/geometry.js'; @@ -1660,6 +1661,16 @@ RigidBodyComponentSystem.prototype.setGravity = function () { } }; +Object.defineProperty(ZoneComponent.prototype, 'size', { + get: function () { + Debug.deprecated('pc.ZoneComponent#size is deprecated. Use pc.ZoneComponent#halfExtents instead.'); + return this.halfExtents; + }, + set: function (halfExtents) { + Debug.deprecated('pc.ZoneComponent#size is deprecated. Use pc.ZoneComponent#halfExtents instead.'); + this.halfExtents = halfExtents; + } +}); export function basisSetDownloadConfig(glueUrl, wasmUrl, fallbackUrl) { Debug.deprecated('pc.basisSetDownloadConfig is deprecated. Use pc.basisInitialize instead.'); diff --git a/src/framework/components/collision/data.js b/src/framework/components/collision/data.js index 2adf57377a2..e50d81da377 100644 --- a/src/framework/components/collision/data.js +++ b/src/framework/components/collision/data.js @@ -16,6 +16,7 @@ class CollisionComponentData { this.asset = null; /** @type {import('../../../framework/asset/asset.js').Asset | number} */ this.renderAsset = null; + this.zoneCheck = false; this.checkVertexDuplicates = true; // Non-serialized properties diff --git a/src/framework/components/collision/system.js b/src/framework/components/collision/system.js index 54aff16f7d6..7038a083df9 100644 --- a/src/framework/components/collision/system.js +++ b/src/framework/components/collision/system.js @@ -36,6 +36,7 @@ const _schema = [ 'shape', 'model', 'render', + 'zoneCheck', 'checkVertexDuplicates' ]; @@ -208,6 +209,7 @@ class CollisionSystemImpl { renderAsset: src.data.renderAsset, model: src.data.model, render: src.data.render, + zoneCheck: src.data.zoneCheck, checkVertexDuplicates: src.data.checkVertexDuplicates }; @@ -691,6 +693,7 @@ class CollisionComponentSystem extends ComponentSystem { 'enabled', 'linearOffset', 'angularOffset', + 'zoneCheck', 'checkVertexDuplicates' ]; @@ -744,6 +747,8 @@ class CollisionComponentSystem extends ComponentSystem { } } + component.data.zoneCheck = !!data.zoneCheck; + const impl = this._createImplementation(data.type); impl.beforeInitialize(component, data); diff --git a/src/framework/components/zone/component.js b/src/framework/components/zone/component.js index 7b46a7c3eed..f796d3a1aa3 100644 --- a/src/framework/components/zone/component.js +++ b/src/framework/components/zone/component.js @@ -1,7 +1,10 @@ import { Vec3 } from '../../../core/math/vec3.js'; +import { Mat4 } from '../../../core/math/mat4.js'; import { Component } from '../component.js'; +const _matrix = new Mat4(); + /** * The ZoneComponent allows you to define an area in world space of certain size. This can be used * in various ways, such as affecting audio reverb when {@link AudioListenerComponent} is within @@ -12,6 +15,45 @@ import { Component } from '../component.js'; * @ignore */ class ZoneComponent extends Component { + /** + * The list of entities currently inside this zone. + * + * @type {import('../../entity').Entity[]} + */ + entities = []; + + /** + * The collision shape for this zone. + * + * @type {Ammo.btCollisionShape|null} + * @private + */ + _collisionShape = null; + + /** + * Last value of halfExtents. + * + * @type {Vec3} + * @ignore + */ + _halfExtentsLast = new Vec3(); + + /** + * Holds all entities added this frame to the zone. + * + * @type {import('../../entity.js').Entity[]} + * @private + */ + _frameAdded = []; + + /** + * Holds all entities removed this frame from the zone. + * + * @type {import('../../entity.js').Entity[]} + * @private + */ + _frameRemoved = []; + /** * Fired when the zone component is enabled. This event does not take into account the enabled * state of the entity or any of its ancestors. @@ -67,56 +109,283 @@ class ZoneComponent extends Component { * @param {import('../../entity.js').Entity} entity - The Entity that this Component is * attached to. */ + // eslint-disable-next-line no-useless-constructor constructor(system, entity) { super(system, entity); + } - this._oldState = true; - this._size = new Vec3(); - this.on('set_enabled', this._onSetEnabled, this); + /** + * Toggle life cycle listeners for this component. + * + * @param {"on"|"off"} onOrOff - Function to use. + * @private + */ + _toggleLifecycleListeners(onOrOff) { + this[onOrOff]('set_useColliders', this._onSetUseColliders, this); + this[onOrOff]('set_shape', this._onSetShape, this); + this[onOrOff]('set_halfExtents', this._onSetHalfExtents, this); + this[onOrOff]('set_radius', this._onSetRadius, this); } /** - * The size of the axis-aligned box of this ZoneComponent. + * Callback function called when property "useColliders" is updated. * - * @type {Vec3} + * @private */ - set size(data) { - if (data instanceof Vec3) { - this._size.copy(data); - } else if (data instanceof Array && data.length >= 3) { - this.size.set(data[0], data[1], data[2]); + _onSetUseColliders() { + if (!this.useColliders) { + this._destroyCollisionShape(); } } - get size() { - return this._size; + /** + * Callback function called when property "shape" is updated. + * + * @private + */ + _onSetShape() { + if (this._collisionShape) { + this._recreateCollisionShape(); + } } - onEnable() { - this._checkState(); + /** + * Callback function called when property "halfExtents" is updated. + * + * @private + */ + _onSetHalfExtents() { + if (this.shape === 'box' && this._collisionShape) { + this._recreateCollisionShape(); + } } - onDisable() { - this._checkState(); + /** + * Callback function called when property "radius" is updated. + * + * @private + */ + _onSetRadius() { + if (this.shape === 'sphere' && this._collisionShape) { + this._recreateCollisionShape(); + } + } + + /** + * Destroy physical shape. + * + * @private + */ + _destroyCollisionShape() { + if (typeof Ammo !== 'undefined') { + if (this._collisionShape) { + Ammo.destroy(this._collisionShape); + this._collisionShape = null; + } + } + } + + /** + * Recreate physical shape. + * + * @private + */ + _recreateCollisionShape() { + if (typeof Ammo !== 'undefined' && this.useColliders) { + this._destroyCollisionShape(); + + const halfExtents = this.halfExtents; + if (this.shape === 'box' && (halfExtents.x !== 0 || halfExtents.y !== 0 || halfExtents.z !== 0)) { + const ammoVec3 = new Ammo.btVector3(halfExtents.x, halfExtents.y, halfExtents.z); + this._collisionShape = new Ammo.btBoxShape(ammoVec3); + Ammo.destroy(ammoVec3); + } else if (this.shape === 'sphere' && this.radius > 0) { + this._collisionShape = new Ammo.btSphereShape(this.radius); + } + } + } + + /** + * Check if a point is within the zone. + * + * @param {Vec3} point - The point to look for. + * @param {Vec3} position - The position of this entity. + * @param {import('../../../core/math/quat').Quat} rotation - The rotation of this entity. + * @param {Mat4} matrix - The matrix for box calculations. + * @returns {boolean} Whether the point is within the zone. + * @private + */ + _isPointInZone(point, position = this.entity.getPosition(), rotation = this.entity.getRotation(), matrix = undefined) { + if (this.shape === 'box') { + if (!matrix) { + matrix = _matrix; + matrix.setTRS(position, rotation, Vec3.ONE); + matrix.invert(); + } + + const localPoint = matrix.transformPoint(point); + const halfExtents = this.halfExtents; + if (Math.abs(localPoint.x) <= halfExtents.x && Math.abs(localPoint.y) <= halfExtents.y && Math.abs(localPoint.z) <= halfExtents.z) { + return true; + } + } else if (point.distance(position) <= this.radius) { + return true; + } + + return false; } - _onSetEnabled(prop, old, value) { - this._checkState(); + /** + * Check if a point is within the zone. + * + * @param {Vec3} point - The point to look for. + * @returns {boolean} Whether the point is within the zone. + */ + isPointInZone(point) { + return this._isPointInZone(point); } - _checkState() { - const state = this.enabled && this.entity.enabled; - if (state === this._oldState) + /** + * Refresh the list of entities within the zone. + * + * @ignore + */ + checkEntities() { + if (!this.entity.enabled || !this.enabled) { return; + } + + const position = this.entity.getPosition(); + const rotation = this.entity.getRotation(); + + _matrix.setTRS(position, rotation, Vec3.ONE); + _matrix.invert(); + + let pendingCollider; + const entities = Object.values(this.system.app._entityIndex); + + this._frameAdded.length = 0; + this._frameRemoved.length = 0; + + // Check entities as per position + for (let i = 0, l = entities.length; i < l; i++) { + const entity = entities[i]; - this._oldState = state; + // Don't check for self. + if (entity === this.entity) { + continue; + } + + const index = this.entities.indexOf(entity); + + if (!entity.enabled) { + if (index !== -1) { + this.entities.splice(index, 1); + } + + continue; + } + + if (this._isPointInZone(entity.getPosition(), position, rotation, _matrix)) { + if (index === -1) { + this.entities.push(entity); + this._frameAdded.push(entity); + } + } else if (this.useColliders && entity.collision && entity.collision.enabled && entity.collision.zoneCheck) { + if (!pendingCollider) { + pendingCollider = []; + } + + pendingCollider.push(entity); + } else if (index !== -1) { + this.entities.splice(index, 1); + this._frameRemoved.push(entity); + } + } - this.fire('enable'); - this.fire('state', this.enabled); + // Check entities as per colliders + if (this.useColliders && pendingCollider && pendingCollider.length && this.system.app.systems.rigidbody) { + if (!this._collisionShape) { + this._recreateCollisionShape(); + } + + if (this._collisionShape) { + if (!this._halfExtentsLast.equals(this.halfExtents)) { + this.fire('set_halfExtents', this._halfExtentsLast, this.halfExtents); + this._halfExtentsLast.copy(this.halfExtents); + } + + const collidingEntities = this.system.app.systems.rigidbody._shapeTestAll(this._collisionShape, position, rotation, false); + + for (let i = 0, l = pendingCollider.length; i < l; i++) { + const entity = pendingCollider[i]; + const collidingIndex = collidingEntities.findIndex(r => r.entity === entity); + const inZoneIndex = this.entities.indexOf(entity); + + if (collidingIndex !== -1 && inZoneIndex !== -1) { + // Entity was already in zone. + continue; + } else if (collidingIndex !== -1) { + // Entity entered zone. + this.entities.push(entity); + this._frameAdded.push(entity); + } else if (inZoneIndex !== -1) { + // Entity left zone. + this.entities.splice(inZoneIndex, 1); + this._frameRemoved.push(entity); + } + } + } + } else if (this._collisionShape) { + this._destroyCollisionShape(); + } + + for (const entity of this._frameAdded) { + entity.fire('zoneEnter', this); + this.fire('entityEnter', entity); + } + for (const entity of this._frameRemoved) { + entity.fire('zoneLeave', this); + this.fire('entityleave', entity); + } + } + + /** + * Function called when the component is getting enabled. + * + * @ignore + */ + onEnable() { + this.system.addZone(this); + this._toggleLifecycleListeners('on'); + this.checkEntities(); } + /** + * Function called when the component is getting disabled. + * + * @ignore + */ + onDisable() { + this.system.removeZone(this); + this._destroyCollisionShape(); + this._toggleLifecycleListeners('off'); + + const entities = [...this.entities]; + this.entities.length = 0; + + for (let i = 0, l = entities.length; i < l; i++) { + entities[i].fire('zoneLeave', this); + } + } + + /** + * Callback function called when component is getting removed. + * + * @ignore + */ _onBeforeRemove() { - this.fire('remove'); + this.onDisable(); } } diff --git a/src/framework/components/zone/data.js b/src/framework/components/zone/data.js index a3140567209..ef1c4858f1b 100644 --- a/src/framework/components/zone/data.js +++ b/src/framework/components/zone/data.js @@ -1,6 +1,12 @@ +import { Vec3 } from '../../../core/math/vec3.js'; + class ZoneComponentData { constructor() { this.enabled = true; + this.type = 'box'; + this.halfExtents = new Vec3(0.5, 0.5, 0.5); + this.radius = 0.5; + this.useColliders = false; } } diff --git a/src/framework/components/zone/system.js b/src/framework/components/zone/system.js index 4d826f4fb8e..752b79d39bb 100644 --- a/src/framework/components/zone/system.js +++ b/src/framework/components/zone/system.js @@ -6,7 +6,7 @@ import { ComponentSystem } from '../system.js'; import { ZoneComponent } from './component.js'; import { ZoneComponentData } from './data.js'; -const _schema = ['enabled']; +const _schema = ['enabled', 'shape', 'halfExtents', 'radius', 'useColliders']; /** * Creates and manages {@link ZoneComponent} instances. @@ -15,46 +15,158 @@ const _schema = ['enabled']; */ class ZoneComponentSystem extends ComponentSystem { /** - * Create a new ZoneComponentSystem. + * Holds all the active zone components. * - * @param {import('../../app-base.js').AppBase} app - The application. + * @type {ZoneComponent[]} + */ + zones = []; + + /** + * Create a new ZoneComponentSystem instance. + * + * @param {import('../../app-base.js').AppBase} app - The Application. * @ignore */ constructor(app) { super(app); + // Defining system name. this.id = 'zone'; + // Defining different types used by system. this.ComponentType = ZoneComponent; this.DataType = ZoneComponentData; + // Define data schema. this.schema = _schema; - this.on('beforeremove', this._onBeforeRemove, this); + // Own listeners. + this.on('add', this.onAdd, this); + this.on('beforeremove', this.onBeforeRemove, this); + this.app.systems.on('update', this.onUpdate, this); } + /** + * Initialize component with default data. + * + * @param {*} component - The component to initialize. + * @param {*} data - The data to initialize the component with. + * @param {*} properties - . + * @ignore + */ initializeComponentData(component, data, properties) { component.enabled = data.hasOwnProperty('enabled') ? !!data.enabled : true; - if (data.size) { - if (data.size instanceof Vec3) { - component.size.copy(data.size); - } else if (data.size instanceof Array && data.size.length >= 3) { - component.size.set(data.size[0], data.size[1], data.size[2]); + if (data.shape) { + component.shape = data.shape; + } + + if (!isNaN(data.radius)) { + component.radius = data.radius; + } + + if (data.hasOwnProperty('useColliders')) { + component.useColliders = !!data.useColliders; + } + + if (data.halfExtents) { + if (data.halfExtents instanceof Vec3) { + component.halfExtents.copy(data.halfExtents); + } else if (data.halfExtents instanceof Array && data.halfExtents.length >= 3) { + component.halfExtents.set(data.halfExtents[0], data.halfExtents[1], data.halfExtents[2]); } + + component._halfExtentsLast.copy(component.halfExtents); } } + /** + * Function to clone component from one entity to another. + * + * @param {import('../../entity').Entity} entity - The entity to get the component from. + * @param {import('../../entity').Entity} clone - The entity to assign the clonned component to. + * @returns {Component} - Result component from clonning. + * @ignore + */ cloneComponent(entity, clone) { - const data = { - size: entity.zone.size - }; + const c = entity.zone; + return this.addComponent(clone, { + size: c.size, + shape: c.shape, + halfExtents: c.halfExtents, + radius: c.radius + }); + } + + /** + * Callback function to call when a component is getting removed. + * + * @param {import('../../entity').Entity} entity - Entity the component is removed from. + * @param {ZoneComponent} component - Component getting removed. + * @private + */ + onAdd(entity, component) { + if (entity.enabled && component.enabled) { + component.onEnable(); + } + } - return this.addComponent(clone, data); + /** + * Callback function to call when a component is getting removed. + * + * @param {import('../../entity').Entity} entity - Entity the component is removed from. + * @param {ZoneComponent} component - Component getting removed. + * @private + */ + onBeforeRemove(entity, component) { + component.onDisable(); + } + + /** + * Callback function to call on every systems update. + * + * @param {number} dt - Time since last update in seconds. + * @private + */ + onUpdate(dt) { + const zones = this.zones; + for (let i = 0, l = zones.length; i < l; i++) { + zones[i].checkEntities(); + } + } + + /** + * Adds a new zone to update. + * + * @param {ZoneComponent} zone - The zone to add. + * @ignore + */ + addZone(zone) { + if (this.zones.indexOf(zone) === -1) { + this.zones.push(zone); + } } - _onBeforeRemove(entity, component) { - component._onBeforeRemove(); + /** + * Remove a zone from updates. + * + * @param {ZoneComponent} zone - The zone to remove. + * @ignore + */ + removeZone(zone) { + const index = this.zones.indexOf(zone); + if (index >= 0) { + this.zones.splice(index, 1); + } + } + + /** + * Destroy the Zone Component System. + */ + destroy() { + super.destroy(); + + this.app.systems.off('update', this.onUpdate, this); } } diff --git a/src/framework/entity.js b/src/framework/entity.js index de2ad5d2e5b..fb7ff5bf5fd 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -204,6 +204,14 @@ class Entity extends GraphNode { */ sprite; + /** + * Gets the {@link ZoneComponent} attached to this entity. + * + * @type {import('./components/zone/component.js').ZoneComponent|undefined} + * @readonly + */ + zone; + /** * Component storage. * @@ -281,6 +289,37 @@ class Entity extends GraphNode { this._app = app; } + /** + * Fired after the entity enters a zone. + * + * @event Entity#zoneEnter + * @param {import('./components/zone/component').ZoneComponent} zone - The zone that entity entered. + * @example + * entity.on('zoneEnter', function (zone) { + * // entity entered a zone + * }); + */ + + /** + * Fired after the entity leaves a zone. + * + * @event Entity#zoneLeave + * @param {import('./components/zone/component').ZoneComponent} zone - The zone that entity left. + * @example + * entity.on('zoneLeave', function (entity) { + * // entity left a zone + * }); + */ + + /** + * List of all zones this entity is currently within. + * + * @type {import('./components/zone/component').ZoneComponent[]} + */ + get zones() { + return this._app.systems.zone.zones.filter(z => z.entities.indexOf(this) !== -1); + } + /** * Create a new component and add it to the entity. Use this to add functionality to the entity * like rendering a model, playing sounds and so on. @@ -308,6 +347,7 @@ class Entity extends GraphNode { * - "scrollview" - see {@link ScrollViewComponent} * - "sound" - see {@link SoundComponent} * - "sprite" - see {@link SpriteComponent} + * - "zone" - see {@link ZoneComponent} * * @param {object} [data] - The initialization data for the specific component type. Refer to * each specific component's API reference page for details on valid values for this parameter.