UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

563 lines (460 loc) • 15.9 kB
import { assert } from "../../core/assert.js"; import Signal from "../../core/events/signal/Signal.js"; import { EntityFlags } from "./EntityFlags.js"; import { EntityReference } from "./EntityReference.js"; import { EventType } from "./EventType.js"; /** * Set of default flags * @type {number} */ const DEFAULT_FLAGS = EntityFlags.RegisterComponents | EntityFlags.WatchDestruction ; /** * Representation of an entity, helps build entities and keep track of them without having to access {@link EntityComponentDataset} directly * * * @example * const entity = new Entity(); * * // Add components to the entity. * entity.add(new Position(10, 20)); * entity.add(new Velocity(1, 2)); * * // Get a component (would return null if the entity doesn't have it). * const position = entity.getComponent(Position); // { x:10, y:20 } * * // Remove a component. * entity.removeComponent(Velocity); * * // To actually use the entity in a game, you need to build it using an EntityComponentDataset. * const dataset = new EntityComponentDataset(); // You would typically have one already * * entity.build(dataset); // add entity with the components to the scene * * // ... Once entity is no longer needed, we destroy it * entity.destroy(); * * @see {@link EntityComponentDataset} * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class Entity { /** * Reference to the entity in a dataset. Do not modify directly. * Only valid when the entity is built, check with {@link isBuilt}. * @readonly * @type {EntityReference} */ reference = new EntityReference(); /** * ID of the entity. Shortcut to {@link reference.id} * @returns {number} */ get id() { return this.reference.id; } /** * Entity's generation tag. Shortcut to {@link reference.generation} * @returns {number} */ get generation() { return this.reference.generation; } /** * Components associated with the entity. Do not modify directly * @readonly * @type {Array} */ components = []; /** * Listeners added before the entity is build live here * @private * @type {{name:string,listener:(function|Function), context:*}[]} */ #deferredListeners = []; /** * Dataset in which this entity exists, if built. If not built - null. * Do not modify directly. * @type {EntityComponentDataset} * @see {@link build} * @see {@link destroy} */ dataset = null; /** * Do not modify directly unless you understand the consequences of doing so. * @type {EntityFlags|number} */ flags = DEFAULT_FLAGS; /** * Arbitrary user data, add anything you want here. * @readonly * @type {Object} */ properties = {}; /** * @readonly */ on = { /** * Fired when {@link build} is called */ built: new Signal() }; /** * Handles event when entity is removed without invoking {@link #destroy} method * @private */ #handleEntityDestroyed() { this.clearFlag(EntityFlags.Built); } /** * * @param {number|EntityFlags} value */ setFlag(value) { this.flags |= value; } /** * * @param {number|EntityFlags} flag * @returns {boolean} */ getFlag(flag) { assert.isNumber(flag, "flag"); return (this.flags & flag) !== 0; } /** * * @param {number|EntityFlags} flag */ clearFlag(flag) { this.flags &= ~flag; } /** * True is the entity is currently attached to a dataset. When this is `true`, {@link dataset} property will be set as well. * @readonly * @returns {boolean} * @see {@link build} * @see {@link destroy} * */ get isBuilt() { return this.getFlag(EntityFlags.Built); } /** * Remove all components from the entity */ removeAllComponents() { const elements = this.components; const n = elements.length; for (let i = n - 1; i >= 0; i--) { const component = elements[i]; const ComponentClass = Object.getPrototypeOf(component).constructor; this.removeComponent(ComponentClass); } } /** * Number of attached components * @return {number} */ get count() { return this.components.length; } /** * Note that this is live-edit, if the {@link Entity} is built - component will be added to the dataset as well. * * @template T class of the component * @param {T} component_instance * @returns {Entity} */ add(component_instance) { assert.defined(component_instance, 'component_instance'); assert.notNull(component_instance, 'component_instance'); assert.notOk(this.hasComponent(Object.getPrototypeOf(component_instance).constructor), 'Component of this type already exists'); this.components.push(component_instance); if (this.getFlag(EntityFlags.Built)) { //already built, add component to entity if (this.getFlag(EntityFlags.RegisterComponents)) { this.dataset.registerComponentType(component_instance.constructor) } this.dataset.addComponentToEntity(this.reference.id, component_instance); } return this; } /** * Check if a component of a given type is present on this entity. * @template T * @param {T} klass type of the component * @returns {boolean} */ hasComponent(klass) { return this.getComponent(klass) !== null; } /** * @template T * @param {Class<T>} klass type of the component * @returns {T|null} component of specified class */ getComponent(klass) { const elements = this.components; const element_count = elements.length; for (let i = 0; i < element_count; i++) { const component = elements[i]; if (component instanceof klass) { return component; } } return null; } /** * Similar to {@link #getComponent}, instead of returning null - throws an exception * @template T * @param {Class<T>} klass * @returns {T} */ getComponentSafe(klass) { const component = this.getComponent(klass); if (component === null) { throw new Error(`Component of given class '${computeComponentClassName(klass)}' not found`); } return component; } /** * Note that this is live-edit, if the {@link Entity} is built - component will be removed from the dataset as well. * @param {function} klass * @returns {*|null} */ removeComponent(klass) { assert.defined(klass, 'klass'); const elements = this.components; const n = elements.length; for (let i = 0; i < n; i++) { const component = elements[i]; if (component instanceof klass) { elements.splice(i, 1); //see if entity is built if (this.getFlag(EntityFlags.Built)) { this.dataset.removeComponentFromEntity(this.reference.id, klass); } return component; } } return null; } /** * * @param {string} eventName * @param {*} [event] */ sendEvent(eventName, event) { assert.isString(eventName, "eventName"); if (this.getFlag(EntityFlags.Built)) { this.dataset.sendEvent(this.reference.id, eventName, event); } else { console.warn("Entity doesn't exist. Event " + eventName + ":" + event + " was not sent.") } } /** * * @param {string} eventName */ promiseEvent(eventName) { assert.isString(eventName, "eventName"); return new Promise((resolve, reject) => { const handle_event = () => { this.removeEventListener(eventName, handle_event); this.removeEventListener(EventType.EntityRemoved, reject); resolve(); } this.addEventListener(eventName, handle_event); this.removeEventListener(EventType.EntityRemoved, reject); }); } /** * * @param {string} eventName * @param {function} listener * @param {*} [context] * @returns {Entity} */ addEventListener(eventName, listener, context) { assert.isString(eventName, "eventName"); assert.isFunction(listener, "listener"); if (this.getFlag(EntityFlags.Built)) { this.dataset.addEntityEventListener(this.reference.id, eventName, listener, context); } else { this.#deferredListeners.push({ name: eventName, listener: listener, context }); } return this; } /** * * @param {string} eventName * @param {function} listener * @param {*} [context] * @returns {Entity} */ removeEventListener(eventName, listener, context) { assert.isString(eventName, "eventName"); assert.isFunction(listener, "listener"); if (this.getFlag(EntityFlags.Built)) { this.dataset.removeEntityEventListener(this.reference.id, eventName, listener, context); } else { const listeners = this.#deferredListeners; for (let i = 0, numListeners = listeners.length; i < numListeners; i++) { const deferredDescriptor = listeners[i]; if ( deferredDescriptor.name === eventName && deferredDescriptor.listener === listener && deferredDescriptor.context === context ) { listeners.splice(i, 1); i--; numListeners--; } } } return this; } /** * Removes built entity from the {@link EntityManager}. * Note that the destroyed {@link Entity} can be later re-built. * @returns {boolean} true if entity was destroyed, false if entity was not built * @see {@link build} * @see {@link isBuilt} */ destroy() { if (!this.getFlag(EntityFlags.Built)) { // not built, do nothing return false; } const dataset = this.dataset; const entity = this.reference.id; //check that the entity is the same as what we have built //assert.ok(checkExistingComponents(entity, this.element, dataset), `Signature of Entity does not match existing entity(id=${entity})`); dataset.removeEntityEventListener(entity, EventType.EntityRemoved, this.#handleEntityDestroyed, this); dataset.removeEntity(entity); // clear reference this.reference.copy(EntityReference.NULL); this.clearFlag(EntityFlags.Built); return true; } /** * Builds entity in the given dataset. * * @example * const ecd:EntityComponentDataset = ...; * const entity:Entity = new Entity(); * entity.add(new Position(10, 20)); * entity.add(new Velocity(1, 2)); * const entity_id = entity.build(ecd); * * @returns {number} entity ID * @param {EntityComponentDataset} dataset * @see {@link destroy} * @see {@link isBuilt} * */ build(dataset) { assert.defined(dataset, "dataset"); assert.notNull(dataset, "dataset"); assert.isObject(dataset, 'dataset'); assert.equal(dataset.isEntityComponentDataset, true, 'dataset.isEntityComponentDataset !== true'); if ( this.getFlag(EntityFlags.Built) && checkExistingComponents(this.reference.id, this.components, dataset) ) { // TODO use 'generation' to avoid checking components //already built return this.reference.id; } const entity = dataset.createEntity(); this.reference.bind(dataset, entity); this.dataset = dataset; let i; const listeners = this.#deferredListeners; const listeners_count = listeners.length; for (i = 0; i < listeners_count; i++) { const subscription = listeners[i]; dataset.addEntityEventListener(entity, subscription.name, subscription.listener, subscription.context); } // reset listeners this.#deferredListeners.splice(0, listeners_count); const element = this.components; const element_count = element.length; if (this.getFlag(EntityFlags.RegisterComponents)) { for (i = 0; i < element_count; i++) { const component = element[i]; dataset.registerComponentType(component.constructor); } } for (i = 0; i < element_count; i++) { const component = element[i]; dataset.addComponentToEntity(entity, component); } this.setFlag(EntityFlags.Built); if (this.getFlag(EntityFlags.WatchDestruction)) { dataset.addEntityEventListener(entity, EventType.EntityRemoved, this.#handleEntityDestroyed, this); } this.on.built.send2(entity, dataset); return entity; } /** * Extract data about an entity and its components from a dataset. * Note that this behaves the same way as if you first created the {@link Entity} and then called {@link build} on it, just the other way around. * * @example * const ecd:EntityComponentDataset = ...; * const entity_id = 7; * const entity:Entity = Entity.readFromDataset(entity_id, ecd); * * @param {number} entity * @param {EntityComponentDataset} dataset * @returns {Entity} */ static readFromDataset(entity, dataset) { assert.isNonNegativeInteger(entity, "entity"); assert.defined(dataset, "dataset"); assert.notNull(dataset, "dataset"); assert.equal(dataset.isEntityComponentDataset, true, "dataset.isEntityComponentDataset !== true"); const r = new Entity(); dataset.readEntityComponents(r.components, 0, entity); r.setFlag(EntityFlags.Built); r.dataset = dataset; r.reference.bind(dataset, entity); return r; } } /** * Useful for a faster alternative to `instanceof` checks * @readonly * @type {boolean} */ Entity.prototype.isEntity = true; /** * * @param {int} entity * @param {Array} components * @param {EntityComponentDataset} dataset */ function checkExistingComponents(entity, components, dataset) { if (!dataset.entityExists(entity)) { return false; } const numComponents = components.length; for (let i = 0; i < numComponents; i++) { const component = components[i]; const actual = dataset.getComponent(entity, component.constructor); if (actual !== component) { return false; } } return true; } export default Entity;