src/entity.js
- /** @ignore */
- import {
- invoke,
- shallowClone,
- isEntityEmpty as isEntityEmpty,
- } from './utilities.js'
-
- /**
- * Entity class used for storing components.
- *
- * @class Entity (name)
- */
- export class Entity {
- /**
- * Do not construct an Entity yourself - use the entity() method in World instead.
- * Also, do not shallow/deep copy entity objects, only pass around references.
- *
- * @private
- *
- * @param {World} world - The world
- * @param {number} id - The identifier
- */
- constructor(world, id) {
- /** @ignore */
- this.world = world
-
- /** @ignore */
- this._id = id
-
- /** @ignore */
- this.data = { entity: this }
- }
-
- /**
- * Return the entity ID.
- *
- * @return {number} Integer entity ID
- */
- get id() {
- return this._id
- }
-
- /**
- * ID is read-only, attempting to set it will throw an error.
- *
- * @private
- *
- * @throws {Error} Cannot set entity id
- */
- set id(id) {
- throw new Error('Cannot set entity id')
- }
-
- /**
- * Returns true if the entity has the specified component name.
- *
- * @example
- * entity.has('position')
- *
- * @param {string} name - The component name to check for
- *
- * @return {boolean} true or false
- */
- has(name) {
- return name in this.data
- }
-
- /**
- * Returns a component by name, or undefined if it doesn't exist
- *
- * @example
- * const position = entity.get('position')
- *
- * @param {string} component - The component name to get
- *
- * @return {Object} The component if defined, otherwise undefined
- */
- get(component) {
- return this.data[component]
- }
-
- /**
- * Returns a component by name (automatically created if it doesn't exist)
- * For unregistered components, passing no arguments to this will create a {} component,
- * if the component does not exist. The argument behavior is exactly the same as set().
- *
- * @example
- * const position = entity.access('position', 3, 4)
- *
- * @example
- * entity.access('anything').foo = 'bar'
- * // 'anything' now has { foo: 'bar' }
- *
- * @example
- * entity.access('anything', { foo: 'bar' }).foo2 = 'bar2'
- * // 'anything' now has { foo: 'bar', foo2: 'bar2' }
- *
- * @param {string} component - The component name to create/get
- * @param {...Object} [args] - The arguments to forward to create the new component, only if it doesn't exist.
- *
- * @return {Object} Always returns either the existing component, or the newly created one.
- */
- access(component, ...args) {
- if (!(component in this.data)) {
- this.set(component, ...args)
- }
- return this.data[component]
- }
-
- /**
- * Adds a new component, or re-creates and overwrites an existing component
- *
- * @example
- * entity.set('position', 1, 2)
- * // If position was registered: { x: 1, y: 2 }
- * // If position was not registered: 1
- * // This is because only the first arg is used for unregistered components
- *
- * @example
- * entity.set('anonymousComponent', { keys: 'values' })
- *
- * @example
- * entity.set('someString', 'Any type of any value')
- *
- * @example
- * entity.set('thisCompIsUndefined', undefined)
- *
- * @example
- * entity.set('thisCompIsEmptyObject')
- *
- * @param {string} compName - The component name to create. If there is a registered component for this name,
- * then its constructor will be called with (...args) and an object of that type will be created. The parent
- * entity reference gets injected into registered components after they are constructed. The onCreate method
- * gets called after the component is added to the entity, and after the entity is injected. The onCreate method
- * also gets passed the same (...args) as the component's constructor.
- * @param {...Object} [args] - The arguments to forward to the registered component type. If the component type
- * is not registered, then only the first additional argument will be used as the value of the entire component.
- *
- * @return {Object} The original entity that set() was called on, so that operations can be chained.
- */
- set(compName, ...args) {
- const compClass = this.world?.entities.componentClasses[compName]
- if (compClass) {
- // Create registered component
- const comp = new compClass(...args)
- comp.entity = this
- this.data[compName] = comp
- } else if (args.length > 0) {
- // Use first argument as component value
- this.data[compName] = args[0]
- } else {
- // Set to an empty object if no args were specified
- this.data[compName] = {}
- }
-
- // Update the index with this component
- if (this.valid()) {
- this.world.entities.addToIndex(this, compName)
- }
-
- // Call custom onCreate to initialize component, and any additional arguments passed into set()
- invoke(this.data[compName], 'onCreate', ...args)
-
- return this
- }
-
- /**
- * Low level method to set components directly. It is recommended to use set() instead, unless
- * you know what you are doing. The onCreate method is not called, and it is expected that you
- * pass an already initialized component. By default, an error will be thrown if you try calling
- * this on a registered component, as that could have unintended consequences in your systems.
- * This method can be useful for saving and restoring components without serialization.
- *
- * @example
- * entity.replace('position', position)
- *
- * @param {string} compName - The component name to set.
- * @param {Object} value - Should be a previous component instance, or whatever is expected for
- * the component name.
- * @param {boolean} overwriteRegistered - Whether or not to proceed with overwriting components
- * in this entity that are registered components. By default, this is false.
- *
- * @return {Object} The original entity that replace() was called on, so that operations can be chained.
- */
- replace(compName, value, overwriteRegistered = false) {
- // Registered component check
- if (
- !overwriteRegistered &&
- this.world?.entities.componentClasses[compName]
- ) {
- throw new Error(
- `Cannot replace() component "${compName}" because it is registered. Please refer to the docs for details.`
- )
- }
-
- // Directly set value
- this.data[compName] = value
-
- // Update the index with this component
- if (this.valid()) {
- this.world.entities.addToIndex(this, compName)
- }
-
- return this
- }
-
- /**
- * Removes a component from the entity - has no effect when it doesn't exist.
- * Can specify an onRemove() method in your component which gets called before it is removed.
- * If nothing is specified, then nothing will be removed.
- * Attempting to remove components that aren't set will be safely ignored.
- *
- * @example
- * entity.remove('position')
- *
- * @param {...string} [components] - The component names to remove from the entity.
- *
- * @return {Object} The original entity that remove() was called on, so that operations can be chained.
- */
- remove(...components) {
- for (const name of components) {
- if (name in this.data) {
- this._removeComponent(name)
- }
- }
- return this
- }
-
- /**
- * Remove this entity and all of its components from the world. After an entity is destroyed,
- * the object should be discarded, and it is recommended to avoid re-using it.
- *
- * @example
- * entity.destroy()
- */
- destroy() {
- if (!this.valid()) {
- throw new Error('Cannot destroy invalid entity')
- }
-
- // Remove all components
- for (const name in this.data) {
- if (name === 'entity') continue
- this._removeComponent(name)
- }
-
- if (!isEntityEmpty(this.data)) {
- throw new Error(
- 'Failed to remove all components. Components must have been added inside onRemove().'
- )
- }
-
- // Remove entity from world
- this.world.entities.entities.delete(this._id)
- this.world = undefined
- this._id = undefined
- }
-
- /**
- * Returns true if this is a valid, existing, and usable entity, which is attached to a world.
- *
- * @example
- * if (entity.valid()) {...}
- *
- * @return {boolean} true or false
- */
- valid() {
- // No need to actually look in the world for the ID, if entities are only ever copied by reference.
- // If entities are ever deep/shallow copied, this function will need to check this to be more robust.
- return this.world != null && this._id != null
- }
-
- /**
- * Serializes entire entity and components to JSON.
- * Defining toJSON methods in your components will override the built-in behavior.
- *
- * @example
- * const serializedEntity = entity.toJSON()
- *
- * @return {string} JSON encoded string
- */
- toJSON() {
- // Don't include "entity" key in serialized data
- const { entity: _, ...comps } = this.data
- return JSON.stringify(comps)
- }
-
- /**
- * Deserializes data from JSON, creating new components and overwriting existing components.
- * Defining fromJSON methods in your components will override the built-in behavior.
- *
- * @example
- * entity.fromJSON(serializedEntity)
- *
- * @param {string} data - A JSON string containing component data to parse, and store in this entity.
- *
- * @return {Object} The original entity that fromJSON() was called on, so that operations can be chained.
- */
- fromJSON(data) {
- const parsed = JSON.parse(data)
- for (const name in parsed) {
- const comp = this.access(name)
-
- // Either call custom method or copy all properties
- if (typeof comp.fromJSON === 'function') {
- comp.fromJSON(parsed[name])
- } else {
- Object.assign(this.access(name), parsed[name])
- }
- }
- return this
- }
-
- /**
- * Attaches a currently detached entity back to a world.
- * Do not use detached entities, get() may be safe, but avoid calling other methods
- * The ID will be reassigned, so do not rely on this
- *
- * @example
- * entity.attach(world)
- *
- * @param {World} world - The world to attach this entity to
- */
- attach(world) {
- if (world && !this.valid()) {
- // Assign new id, and reattach to world
- this.world = world
- this._id = this.world.entities.nextEntityId++
- this.world.entities.entities.set(this._id, this)
- for (const name in this.data) {
- if (name === 'entity') continue
- this.world.entities.addToIndex(this, name)
- }
- }
- }
-
- /**
- * Removes this entity from the current world, without removing any components or data.
- * It can be re-attached to another world (or the same world), using the attach() method.
- * Do not use detached entities, get() may be safe, but avoid calling other methods
- * The ID will be reassigned, so do not rely on this
- *
- * @example
- * entity.detach()
- */
- detach() {
- if (this.valid()) {
- // Remove from current world
- for (const name in this.data) {
- if (name === 'entity') continue
- this.world.entities.removeFromIndex(this, name)
- }
- this.world.entities.entities.delete(this._id)
- this._id = undefined
- this.world = undefined
- }
- }
-
- /**
- * Creates a copy of this entity with all of the components cloned and returns it.
- * Individual components are either shallow or deep copied, depending on component
- * registration status and if a clone() method is defined.
- *
- * @example
- * const clonedEntity = entity.clone()
- *
- * @example
- * // How to define custom clone methods
- * world.component('foo', class {
- * onCreate(bar, baz) {
- * this.bar = bar
- * this.baz = baz
- * this.qux = false
- * }
- * setQux(qux = true) {
- * this.qux = qux
- * }
- * cloneArgs() {
- * return [this.bar, this.baz]
- * }
- * clone(target) {
- * target.qux = this.qux
- * }
- * })
- */
- clone() {
- if (!this.valid()) {
- throw new Error('Cannot clone detached or invalid entity.')
- }
-
- // Clone each component in this entity, to a new entity
- const newEntity = this.world.entity()
- for (const name in this.data) {
- if (name === 'entity') continue
- this._cloneComponentTo(newEntity, name)
- }
-
- // Return the cloned entity
- return newEntity
- }
-
- /**
- * @ignore
- * Clones a component from this entity to the target entity.
- *
- * @example
- * const source = world.entity().set('foo', 'bar')
- * const target = world.entity()
- * source._cloneComponentTo(target, 'foo')
- * assert(target.get('foo') === 'bar')
- *
- * @example
- * world.component('foo', class {
- * onCreate(bar, baz) {
- * this.bar = bar
- * this.baz = baz
- * this.qux = false
- * }
- * setQux(qux = true) {
- * this.qux = qux
- * }
- * cloneArgs() {
- * return [this.bar, this.baz]
- * }
- * clone(target) {
- * target.qux = this.qux
- * }
- * })
- * const source = world.entity()
- * .set('foo', 'bar', 'baz')
- * .set('qux', true)
- * const target = world.entity()
- * source._cloneComponentTo(target, 'foo')
- * assert(source.get('foo').bar === target.get('foo').bar)
- * assert(source.get('foo').baz === target.get('foo').baz)
- * assert(source.get('foo').qux === target.get('foo').qux)
- *
- * @param {Entity} targetEntity - Must be a valid entity. Could be part of another world, but it
- * is undefined behavior if the registered components are different types.
- * @param {string} name - Component name of both source and target components.
- *
- * @return {Object} The original entity that _cloneComponentTo() was called on,
- * so that operations can be chained.
- */
- _cloneComponentTo(targetEntity, name) {
- // Get component and optional arguments for cloning
- const component = this.get(name)
- const args = invoke(component, 'cloneArgs') || []
- const compClass = targetEntity.world.entities.componentClasses[name]
- if (compClass) {
- // Registered component, so create new using constructor, inject
- // entity, and call optional clone
- const newComponent = new compClass(...args)
- newComponent.entity = targetEntity
- targetEntity.data[name] = newComponent
- invoke(component, 'clone', newComponent)
- } else {
- // Unregistered component, so just shallow clone it
- targetEntity.data[name] = shallowClone(component)
- }
-
- // Update the index with this new component
- targetEntity.world.entities.addToIndex(targetEntity, name)
-
- // Call custom onCreate to initialize component, and any additional arguments passed into set()
- invoke(targetEntity.data[name], 'onCreate', ...args)
-
- return this
- }
-
- /** @ignore */
- _removeComponent(compName) {
- // Call custom onRemove
- invoke(this.data[compName], 'onRemove')
-
- // Remove from index
- this.world?.entities.removeFromIndex(this, compName)
-
- // Remove from entity
- delete this.data[compName]
- }
- }