engine.js

/**
 * The engine module exports the Engine class
 *
 * @module engine
 */

import _ from 'lodash';
import EventEmitter from 'events';
import Entity from './entity';
import System from './system';

/**
 * The Engine class
 *
 * @extends EventEmitter
 */
class Engine extends EventEmitter {
  constructor() {
    super();

    /**
     * Declared entities
     *
     * @private
     * @type Object
    */
    this.entities = {};

    /**
     * Declared systems
     *
     * @private
     * @type System[]
     */
    this.systems = [];

    /**
     * Entities associated to systems
     *
     * @private
     * @type Object
     */
    this.systemVsEntities = {};
  }

  /**
   * Declare a new entity
   *
   * @param {String} name Name of the new entity
   * @return {Entity} The newly created entity object
   */
  entity(name) {
    if (typeof name !== 'string') throw new Error('name must be a string');
    if (this.entities[name] !== undefined) throw new Error(`Entity ${name} already exists`);
    const entity = new Entity(/* this, */ name);
    entity.on('component:add', () => this.updateSystemsVsEntities());
    entity.on('component:delete', () => this.updateSystemsVsEntities());
    entity.on('entity:remove', (entityName) => this.removeEntity(entityName));
    this.entities[name] = entity;
    return entity;
  }

  /**
   * Returns existing entity
   *
   * @param {String} name Name of the entity
   * @return {Entity} The entity object or undefined if not found
   */
  getEntity(name) {
    if (typeof name !== 'string') throw new Error('name must be a string');
    return this.entities[name];
  }

  /**
   * Remove a entity from the engine instance
   *
   * @param {String} name The name of the entity to be removed
   */
  removeEntity(name) {
    delete this.entities[name];

    // update system vs entity associations
    this.updateSystemsVsEntities();
  }

  /**
   * Declare a new system.<br/>
   * The handler function receives two arguments, the name of the entity and a object
   * of components.
   *
   * @param {String} name Name of the new system
   * @param {string[]} components Names of the components the new system will operate on
   * @param {Function} handler System function
   * @return {System} The newly created system object
   */
  system(name, components, handler) {
    if (typeof name !== 'string') throw new Error('name must be a string');
    if (!(_.isArrayLike(components) && _.every(components, _.isString))) throw new Error('components must be a string array');
    if (typeof handler !== 'function') throw new Error('handler must be a function');

    // Systems is an array instead of a map to guarantee execution order
    if (_.some(this.systems, (system) => system.name === name)) {
      throw new Error(`System ${name} already exists`);
    }
    const system = new System(/* this, */ name, components, handler);
    system.on('system:remove', (systemName) => this.removeSystem(systemName));
    this.systems.push(system);

    // Update system vs entity associations
    this.updateSystemsVsEntities();
    return system;
  }

  /**
   * Returns existing system
   *
   * @param {String} name Name of the system
   * @return {System} The system object or undefined if not found
   */
  getSystem(name) {
    if (typeof name !== 'string') throw new Error('name must be a string');
    return _.find(this.systems, (system) => system.name === name);
  }

  /**
   * Remove a system from the engine instance
   *
   * @param {String} name The name of the system to be removed
   */
  removeSystem(name) {
    if (typeof name !== 'string') throw new Error('name must be a string');
    this.systems = _.filter(this.systems, (system) => system.name !== name);

    // update system vs entity associations
    this.updateSystemsVsEntities();
  }

  /**
   * Scan systems and search for suitable entities
   * to be associated.
   *
   * @private
   */
  updateSystemsVsEntities() {
    _.forEach(this.systems, (system) => {
      const systemName = system.name;
      const compatibleEntities = [];
      this.systemVsEntities[systemName] = compatibleEntities;

      _.forEach(this.entities, (entity) => {
        if (system.isCompatibleEntity(entity)) {
          compatibleEntities.push(entity);
        }
      });
    });
  }

  /**
   * Run a single execution step.<br/>
   * Emit a TICK_BEFORE event before running the systems and a TICK_AFTER event after running them.
   *
   * @return {Number} Current simulation time
   */
  tick() {
    /**
     * Emitted by the engine just before running the systems.<br/>
     * The payload is the Engine instance that originated the event.
     * @event tick:before
     * @type {Engine}
     */
    this.emit('tick:before', this);

    _.forEach(this.systems, (system) => {
      _.forEach(this.systemVsEntities[system.name], (entity) => {
        const components = {};
        _.forEach(system.components, (name) => {
          components[name] = entity.components[name];
        });
        system.handler(entity, components);
      });
    });

    /**
     * Emitted by then engine after running all the systems.<br/>
     * The payload is the Engine instance that originated the event.
     * @event tick:after
     * @type {Engine}
     */
    this.emit('tick:after', this);
  }
}

export default Engine;