UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

450 lines (353 loc) • 10.8 kB
import { assert } from "../../../core/assert.js"; import { array_push_if_unique } from "../../../core/collection/array/array_push_if_unique.js"; import { array_remove_first } from "../../../core/collection/array/array_remove_first.js"; import Signal from "../../../core/events/signal/Signal.js"; import Entity from "../Entity.js"; import { TransformAttachment, TransformAttachmentFlags } from "../transform-attachment/TransformAttachment.js"; import { Transform } from "../transform/Transform.js"; import { EntityNodeFlags } from "./EntityNodeFlags.js"; import { ParentEntity } from "./ParentEntity.js"; const DEFAULT_FLAGS = EntityNodeFlags.LiveManagement; /** * Scene-graph abstraction. * This is syntactic sugar on top of {@link TransformAttachment} and {@link ParentEntity} components that makes working with hierarchies easier. */ export class EntityNode { /** * * @type {EntityNode|null} * @private */ __parent = null; /** * * @type {EntityNode[]} * @private */ __children = []; /** * Local transform * @type {Transform} * @private */ __transform = new Transform(); on = { built: new Signal(), destroyed: new Signal() }; /** * * @type {number} */ flags = DEFAULT_FLAGS; /** * * @param {Entity} [entity] optional entity to be wrapped */ constructor(entity = new Entity()) { /** * * @type {Entity} * @private */ this.__entity = entity; } /** * * @param {number|EntityNodeFlags} flag * @returns {void} */ setFlag(flag) { this.flags |= flag; } /** * * @param {number|EntityNodeFlags} flag * @returns {void} */ clearFlag(flag) { this.flags &= ~flag; } /** * * @param {number|EntityNodeFlags} flag * @param {boolean} value */ writeFlag(flag, value) { if (value) { this.setFlag(flag); } else { this.clearFlag(flag); } } /** * * @param {number|EntityNodeFlags} flag * @returns {boolean} */ getFlag(flag) { return (this.flags & flag) === flag; } /** * * @param {function(node:EntityNode):*} visitor * @param {*} [thisArg] */ traverse(visitor, thisArg) { visitor.call(thisArg, this); const children = this.__children; const n = children.length; for (let i = 0; i < n; i++) { const child = children[i]; child.traverse(visitor, thisArg); } } /** * * @param {function(node:EntityNode):*} visitor * @param {*} [context] */ traverseChildren(visitor, context) { this.__children.forEach(visitor, context); } /** * * @return {Readonly<Array<EntityNode>>} */ get children() { return this.__children; } /** * * @private */ __transform_sync_down() { const transform = this.__transform; if (this.__parent === null) { // not attached to anything, world and local transforms match const cTransform = this.__entity.getComponent(Transform); if (cTransform !== null) { cTransform.copy(transform); } } else { this.__safe_get_attachment().transform.copy(transform); } } /** * * @param {*} components * @return {EntityNode} */ static fromComponents(...components) { const node = new EntityNode(); for (let i = 0; i < components.length; i++) { const component = components[i]; node.entity.add(component); } return node; } /** * * @return {Attachment} * @private */ __safe_get_attachment() { const entity = this.__entity; let attachment = entity.getComponent(TransformAttachment); if (attachment === null) { attachment = new TransformAttachment(); attachment.setFlag(TransformAttachmentFlags.Immediate); entity.add(attachment); } return attachment; } /** * @returns {Transform} */ get transform() { return this.__transform; } /** * Root of the hierarchy, will be `this` if node is already a root * @return {EntityNode} */ get root(){ let node = this; while(node.__parent !== null){ node = node.__parent; } return node; } /** * * @return {EntityNode|null} */ get parent() { return this.__parent; } /** * * @param {EntityNode|null} node */ set parent(node) { this.__parent = node; const this_entity = this.__entity; // check if entity is built if (!this_entity.isBuilt) { return; } let parent_entity = this_entity.removeComponent(ParentEntity); let attachment = this_entity.removeComponent(TransformAttachment); if (node !== null) { // handle component ParentEntity if (parent_entity === null) { parent_entity = new ParentEntity(); } const parent_entity_builder = node.entity; if (!parent_entity_builder.isBuilt) { throw new Error('Parent entity is not built'); } const parent_entity_id = parent_entity_builder.id; parent_entity.entity = parent_entity_id; // add component back this_entity.add(parent_entity); // handle component Attachment if (attachment === null) { attachment = this.__safe_get_attachment(); } attachment.parent = parent_entity_id; } } /** * * @return {Entity} */ get entity() { return this.__entity; } /** * * @param {EntityNode} node * @returns {boolean} */ addChild(node) { const added = array_push_if_unique(this.__children, node); if (!added) { // already contain this child return false; } if (node.parent !== null) { // has a parent already, detach node.parent.removeChild(node); } node.parent = this; // live mode if (this.__entity.isBuilt) { node.build(this.__entity.dataset); } return true; } /** * * @param {EntityNode} node * @returns {boolean} */ removeChild(node) { const removed = array_remove_first(this.__children, node); if (!removed) { // not present return false; } assert.equal(node.parent, this, 'node is a child, but parent property points to something else instead of this node'); node.parent = null; return true; } get isBuilt() { return this.__entity.isBuilt; } attachListeners() { if (this.getFlag(EntityNodeFlags.TransformObserved)) { // already observed return; } this.__transform.position.onChanged.add(this.__transform_sync_down, this); this.__transform.scale.onChanged.add(this.__transform_sync_down, this); this.__transform.rotation.onChanged.add(this.__transform_sync_down, this); this.setFlag(EntityNodeFlags.TransformObserved); } detachListeners() { this.__transform.position.onChanged.remove(this.__transform_sync_down, this); this.__transform.scale.onChanged.remove(this.__transform_sync_down, this); this.__transform.rotation.onChanged.remove(this.__transform_sync_down, this); this.clearFlag(EntityNodeFlags.TransformObserved); } /** * * @param {EntityComponentDataset} ecd */ build(ecd) { assert.notOk(this.__entity.isBuilt, 'Already built'); // assume that parent is built const parent = this.__parent; if (parent !== null) { /** * * @type {ParentEntity} */ let parent_entity = this.__entity.getComponent(ParentEntity); if (parent_entity === null) { parent_entity = new ParentEntity(); this.__entity.add(parent_entity); } const parent_entity_builder = parent.__entity; if (!parent_entity_builder.isBuilt) { throw new Error('Parent entity is not built'); } const parent_entity_id = parent_entity_builder.id; parent_entity.entity = parent_entity_id; assert.ok(parent.entity.hasComponent(Transform), "parent node must have a transform but doesn't. Transform is required for attachment transform hierarchy to work correctly"); const attachment = this.__safe_get_attachment(); attachment.parent = parent_entity_id; } this.__transform_sync_down(); // attach listeners if (this.getFlag(EntityNodeFlags.LiveManagement)) { this.attachListeners(); } this.__entity.build(ecd); // build children const children = this.__children; const child_count = children.length; for (let i = 0; i < child_count; i++) { const child = children[i]; child.build(ecd); } this.on.built.send0(); } destroy() { if (!this.__entity.isBuilt) { // not built //TODO check for dangling listeners return; } if (this.getFlag(EntityNodeFlags.TransformObserved)) { // remove listeners this.detachListeners(); } // destroy children first const children = this.__children; const child_count = children.length; for (let i = child_count - 1; i >= 0; i--) { const child = children[i]; child.destroy(); } // then main entity this.__entity.destroy(); this.on.destroyed.send0(); } } /** * @readonly * @type {boolean} */ EntityNode.prototype.isEntityNode = true;