import { Events, Observer, ObserverHistory } from '@playcanvas/observer';
import { globals as api } from './globals';
import { Guid } from './guid';
* Represents an observer for an entity, extending the base Observer.
*/
export type EntityObserver = Observer & {
* The API entity associated with this observer.
*/
apiEntity: Entity;
* The history of changes made to the observer.
*/
history: ObserverHistory;
* A function that returns the latest observer.
*/
latestFn: () => Observer;
* The Engine entity associated with this observer.
*/
entity: any;
};
* The Entity class represents an entity in the Editor.
*/
class Entity extends Events {
private _observer: EntityObserver;
private _history: ObserverHistory | object;
* Creates new Entity
*
* @category Internal
* @param data - Optional entity data
*/
constructor(data: any = {}) {
super();
let name = data.name;
if (name === undefined || typeof name !== 'string') {
name = 'New Entity';
}
const observerData: {
name: string;
tags: string[];
enabled: boolean;
resource_id: string;
parent: string | null;
children: EntityObserver[];
position: number[];
rotation: number[];
scale: number[];
components: object;
template: number | null;
template_id: number | null;
template_ent_ids: Record<string, string> | null;
} = {
name: name,
tags: data.tags || [],
enabled: data.enabled !== undefined ? !!data.enabled : true,
resource_id: data.resource_id || Guid.create(),
parent: typeof data.parent === 'string' ? data.parent : null,
children: [] as EntityObserver[],
position: data.position || [0, 0, 0],
rotation: data.rotation || [0, 0, 0],
scale: data.scale || [1, 1, 1],
components: {},
template: data.template !== undefined ? data.template : null,
template_id: data.template_id !== undefined ? data.template_id : null,
template_ent_ids: data.template_ent_ids !== undefined ? data.template_ent_ids : null
};
this._observer = new Observer(observerData) as EntityObserver;
this._observer.addEmitter(this);
const id = this._observer.get('resource_id');
this._observer.latestFn = () => {
const latest = api.entities.get(id);
return latest && latest._observer;
};
this._observer.apiEntity = this;
if (data.components) {
for (const component in data.components) {
this.addComponent(component, data.components[component]);
}
}
this._history = {};
}
_initializeHistory() {
if (this._observer.history) {
return;
}
this._history = new ObserverHistory({
item: this._observer,
prefix: `entity.${this.get('resource_id')}.`,
history: api.history
});
this._observer.history = this._history as ObserverHistory;
}
* Checks if path exists. See {@link Entity} for a list of properties.
*
* @param path - The path
* @returns True if path exists
* @example
* ```javascript
* console.log(entity.has('components.model'));
* ```
*/
has(path: string) {
return this._observer.has(path);
}
* Gets value at path. See {@link Entity} for a list of properties.
*
* @param path - The path
* @returns The value
* @example
* ```javascript
* console.log(entity.get('position'));
* ```
*/
get(path: string) {
return this._observer.get(path);
}
* Sets value at path. See {@link Entity} for a list of properties.
*
* @param path - The path
* @param value - The value
* @returns Whether the value was set
* @example
* ```javascript
* entity.set('position', [1, 0, 0]);
* ```
*/
set(path: string, value: any) {
return this._observer.set(path, value);
}
* Unsets value at path. See {@link Entity} for a list of properties.
*
* @param path - The path
* @returns Whether the value was unset
* @example
* ```javascript
* entity.unset('components.model');
* ```
*/
unset(path: string) {
return this._observer.unset(path);
}
* Inserts value in array at path, at specified index. See {@link Entity} for a list of properties.
*
* @param path - The path
* @param value - The value
* @param index - The index (if undefined the value will be inserted in the end)
* @returns Whether the value was inserted
* @example
* ```javascript
* entity.insert('tags', 'a_tag');
* ```
*/
insert(path: string, value: any, index?: number) {
return this._observer.insert(path, value, index);
}
* Remove value from array at path. See {@link Entity} for a list of properties.
*
* @param path - The path
* @param value - The value
* @returns Whether the value was removed
* @example
* ```javascript
* entity.removeValue('tags', 'a_tag');
* ```
*/
removeValue(path: string, value: any) {
return this._observer.removeValue(path, value);
}
* Returns JSON representation of entity data
*
* @returns - The data
* ```javascript
* console.log(entity.json());
* ```
*/
json() {
return this._observer.json();
}
* Returns a JSON representation of entity data. The children array
* of the entity gets recursively converted to an array of entity data
* instead of containing children resource ids.
*
* @returns - The data
* @example
* ```javascript
* const data = entity.jsonHierarchy();
* console.log(data.children[0].name);
* ```
*/
jsonHierarchy() {
const result = this.json() as any;
const children = this.children;
for (let i = 0; i < children.length; i++) {
result.children[i] = children[i] && children[i].jsonHierarchy();
}
return result;
}
* Returns true if this entity is a descendant of the specified parent entity.
*
* @param parent - The parent
* @returns True if it is
*/
isDescendantOf(parent: Entity) {
let p = this.parent;
while (p) {
if (p === parent) {
return true;
}
p = p.parent;
}
return false;
}
* Finds first entity by name using depth-first search
*
* @param name - The name
* @returns The entity
* @example
* ```javascript
* const door = editor.entities.root.findByName('Door');
* ```
*/
findByName(name: string): Entity | null {
if (this.get('name') === name) {
return this;
}
const children = this.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child) {
const found = child.findByName(name);
if (found) {
return found;
}
}
}
return null;
}
* Finds all entities with specified tags
*
* @param tags - The tags. If multiple tags are specified then entities that contain ANY of the specified
* tags will be included. If an argument is an array of tags then entities that contain ALL of the tags in the array will be included.
* @returns The entities
* @example
* ```javascript
* // entities that have the following tag
* const entities = editor.entities.root.listByTag('tag');
* // entities that have any of the following tags
* const entities = editor.entities.root.listByTag('tag', 'tag2');
* // entities that have all of the following tags
* const entities = editor.entities.root.listByTag(['tag', 'tag2']);
* ```
*/
listByTag(...tags: any[]) {
return this.filter((entity: Entity) => {
const t = entity.get('tags');
for (let i = 0; i < tags.length; i++) {
const tag = tags[i];
if (Array.isArray(tag)) {
let countTags = 0;
for (let i = 0; i < tag.length; i++) {
const subTag = tag[i];
if (t.includes(subTag)) {
countTags++;
} else {
break;
}
}
if (countTags === tag.length) {
return true;
}
} else {
if (t.includes(tag)) {
return true;
}
}
}
return false;
});
}
* Returns the entity and children that satisfy the function
*
* @param fn - A function that takes an Entity and returns whether it should be included
* in the result
* @returns The result
* @example
* ```javascript
* const doors = editor.entities.root.filter(entity => entity.get('name').startsWith('door'));
* ```
*/
filter(fn: (entity: Entity) => boolean): Entity[] {
let result: Entity[] = [];
if (fn(this)) {
result.push(this);
}
const children = this.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child) {
result = result.concat(child.filter(fn));
}
}
return result;
}
* Executes function for this entity and its children in depth first order.
*
* @param fn - A function that takes an entity as an argument
* @example
* ```javascript
* // get a list of all entities in the graph in depth first order
* const entities = [];
* editor.entities.root.depthFirst(entity => entities.push(entity));
* ```
*/
depthFirst(fn: (entity: Entity) => void) {
fn(this);
const children = this.children;
children.forEach((child: Entity) => {
if (child) {
child.depthFirst(fn);
} else {
fn(child);
}
});
}
* Adds a component to this Entity
*
* @param component - The component name
* @param data - Default component data. Defaults values will be used for any missing fields.
* For details on component properties see {@link Entity}.
* @example
* ```javascript
* editor.entities.root.addComponent('model', {
* type: 'box'
* });
* ```
*/
addComponent(component: string, data = {}) {
const defaultData = api.schema.components.getDefaultData(component);
const componentData = Object.assign(defaultData, data);
this.set(`components.${component}`, componentData);
}
* Removes a component from this Entity
*
* @param component - The component name
* @example
* ```javascript
* editor.entities.root.removeComponent('model');
* ```
*/
removeComponent(component: string) {
this.unset(`components.${component}`);
}
* Adds entity as a child
*
* @category Internal
* @param entity - The entity
* @returns Whether the child was added
*/
addChild(entity: Entity) {
return this.insertChild(entity);
}
* Inserts entity as a child at specified index.
*
* @category Internal
* @param entity - The entity
* @param index - The index. If undefined the child will be added in the end.
* @returns Whether the child was added
*/
insertChild(entity: Entity, index: number | undefined = undefined) {
let history = this.history.enabled;
this.history.enabled = false;
const result = this.insert('children', entity.get('resource_id'), index);
this.history.enabled = history;
if (!api.entities.get(entity.get('resource_id'))) {
console.error(
`BUG TRACKING: inserting missing child guid ${entity.get('resource_id')} to parent ${this.get('resource_id')}`
);
}
if (result) {
history = entity.history.enabled;
entity.history.enabled = false;
entity.set('parent', this.get('resource_id'));
entity.history.enabled = history;
return true;
}
console.error(
`Cannot add duplicate child ${entity.get('resource_id')} under parent ${this.get('resource_id')}`
);
return false;
}
* Removes entity from children
*
* @category Internal
* @param entity - The entity
*/
removeChild(entity: Entity) {
let history = entity.history.enabled;
entity.history.enabled = false;
try {
entity.observer.set('parent', null, true);
} catch (err) {
console.error(`Error when setting parent to null for entity ${entity.get('resource_id')}`);
console.error(err);
}
entity.history.enabled = history;
history = this.history.enabled;
this.history.enabled = false;
try {
this.removeValue('children', entity.get('resource_id'));
} catch (err) {
console.error(
`Error when removing ${entity.get('resource_id')} from children of entity ${this.get('resource_id')}`
);
console.error(err);
}
this.history.enabled = history;
}
* Deletes entity (and its children)
*
* @param options.history - Whether to record a history action. Defaults to true.
* @returns A promise
* @example
* ```javascript
* editor.entities.root.findByName('door').delete();
* ```
*
*/
delete(options: { history?: boolean; preserveEntityReferences?: boolean } = {}) {
return api.entities.delete([this], options);
}
* Reparents entity under new parent
*
* @param parent - The new parent
* @param index - The desired index. If undefined the entity will be added at the end of the parent's children.
* @param options.history - Whether to record a history action. Defaults to true.
* @param options.preserveTransform - Whether to preserve the original transform after reparenting
* @example
* ```javascript
* const redHouse = editor.entities.root.findByName('red house');
* const greenHouse = editor.entities.root.findByName('green house');
* const door = redHouse.findByName('door');
* door.reparent(greenHouse);
* ```
*/
reparent(
parent: Entity,
index: number | null = null,
options: { history?: boolean; preserveTransform?: boolean } = {}
) {
api.entities.reparent(
[
{
entity: this,
parent: parent,
index: index
}
],
options
);
}
* Duplicates entity under the same parent
*
* @param options.history - Whether to record a history action. Defaults to true.
* @param options.select - Whether to select the new entity. Defaults to false.
* @param options.rename - Whether to rename the duplicated entity. Defaults to false.
* @returns The new entity
*/
async duplicate(options: { history?: boolean; select?: boolean; rename?: boolean } = {}) {
const result = await api.entities.duplicate([this], options);
return result[0];
}
* Returns the latest version of the Entity from the Entities API.
*
* @returns The entity
*/
latest() {
return api.entities.get(this._observer.get('resource_id'));
}
* Adds a script to the script component of this entity.
* If a script component does not exist, this method will add the script
* component as well.
*
* @param scriptName - The name of the script.
* @param options.attributes - The values of attributes. Each key is the name
* of the attributes and each value is the value for that attribute. Leave undefined to
* let the Editor set default values depending on the attribute types.
* @param options.history - Whether to add a history action. Defaults to true.
* @param options.index - The desired index in the entity's scripts order to add this script.
* @returns A promise
*/
addScript(scriptName: string, options: { attributes?: object; history?: boolean; index?: number } = {}) {
return api.entities.addScript([this], scriptName, options);
}
* Removes a script from the entity's script component.
*
* @param scriptName - The name of the script.
* @param options.history - Whether to record a history action. Defaults to true.
*/
removeScript(scriptName: string, options: { history?: boolean } = {}) {
api.entities.removeScript([this], scriptName, options);
}
* The observer object for this entity.
*/
get observer() {
return this._observer;
}
* The parent entity.
*/
get parent() {
const id = this.get('parent');
return (id ? api.entities.get(id) : null) as Entity;
}
* The children entities. Warning: this creates a new array every time it's called.
*/
get children() {
return this.get('children').map((id: string) => api.entities.get(id)) as Entity[];
}
* The history object for this entity.
*/
get history() {
return this._history as ObserverHistory;
}
* The entity in the 3D viewport of the Editor.
*/
get viewportEntity() {
return (this._observer ? (this._observer as any).entity : null) as any;
}
}
export { Entity };