@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
563 lines (460 loc) • 15.9 kB
JavaScript
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;