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