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]
}
}