engine.js

import EntityManager from './entity-manager';
import Promise from 'bluebird';
import Symbol from 'es6-symbol';
import EventEmitter from 'eventemitter3';
import values from 'lodash/values';
import forEach from 'lodash/forEach';
import set from 'lodash/set';
import {
  assertDisposable,
  assertEntityManager,
  assertSystem,
  assertNumber,
} from './asserts';
import createClass from './util/create-class';
import decorateObject from './util/decorate-object';
import subscribableMixin from './mixins/subscribable';
import disposableMixin from './mixins/disposable';
import disposableDecorator from './mixins/decorator/disposable-decorator';
import Entity from './entity';

const FIELDS = {
  manager: Symbol('manager'),
  systems: Symbol('systems'),
  process: Symbol('process'),
  interval: Symbol('interval'),
  executedOn: Symbol('executedOn'),
  state: Symbol('state'),
  emitter: Symbol('emitter'),
};

const STATE = {
  PENDING: 0,
  INITIALIZED: 1,
  IDLE: 2,
  RUNNING: 3,
  STOPPING: 4,
  STOPPED: 6,
  ERROR: 7,
};

function createEntity() {
  const entity = new Entity();
  this[FIELDS.manager].addEntities(entity);
  return entity;
}

function onError(error) {
  this[FIELDS.emitter].emit('error', error);
}

function onDispose() {
  this.stop();
  this[FIELDS.emitter].emit('dispose');
  this[FIELDS.emitter].removeAllListeners();
}

/**
 * @class
 * The engine manages all entities and the main system loop. Adding new entities to
 * the engine will automatically set them up to be watched by the registered systems.
 * @example
 * #Creating a new engine.
 * const engine = new Engine({
 *     interval: 16,
 * });
 * @param {number} [config.interval=16] - The number of milliseconds between executions.
 * @access public
 * @mixes Subscribable
 * @mixes Disposable
 */
const Engine = createClass({

  mixins: [
    decorateObject(subscribableMixin(FIELDS.emitter), disposableDecorator),
    disposableMixin(values(FIELDS), onDispose),
  ],

  constructor({ manager = new EntityManager(), interval = 16 } = { manager: new EntityManager(), interval: 16 }) {
    assertEntityManager(manager);
    assertNumber(interval);

    this[FIELDS.interval] = interval;
    this[FIELDS.manager] = manager;
    this[FIELDS.systems] = [];
    this[FIELDS.state] = STATE.PENDING;
    this[FIELDS.emitter] = new EventEmitter();

    manager.subscribe('error', onError.bind(this));
  },

  /**
   * Get the engine state.
   * @returns {string} The engine state.
   * @throws {ReferenceError} Will throw this error if invoked after being disposed.
   * @instance
   * @memberof Engine
   */
  getState() {
    assertDisposable(this);
    return this[FIELDS.state];
  },

  /**
   * Adds all specified entities to the engine.
   * @param {...entities} entities - The entities.
   * @returns {Engine} Itself
   * @throws {ReferenceError} Will throw this error if invoked after the engine has been disposed.
   * @memberof Engine
   * @instance
   */
  addEntities(...entities) {
    assertDisposable(this);
    const self = this;

    function addEntity(entity) {
      self[FIELDS.manager].addEntities(entity);
      entity.subscribe('error', onError.bind(self));
    }

    forEach(entities, addEntity);
    return this;
  },

  /**
   * Removes all specified entities from the engine.
   * @param {...(Entity|number)} entitiesOrIds - The entities or entity ids.
   * @returns {Engine} Itself
   * @throws {ReferenceError} Will throw this error if invoked after the engine has been disposed.
   * @memberof Engine
   * @instance
   */
  removeEntities(...entitiesOrIds) {
    assertDisposable(this);
    this[FIELDS.manager].removeEntities(...entitiesOrIds);
    return this;
  },

  /**
   * Adds all specified systems to the engine.
   * @param {...System} systems - The systems.
   * @returns {Engine} Itself
   * @throws {TypeError}
   * @throws {ReferenceError} Will throw this error if invoked after the engine has been disposed.
   * @memberof Engine
   * @instance
   */
  addSystems(...systems) {
    assertDisposable(this);
    const self = this;

    forEach(systems, (system) => {
      assertSystem(system);
      system.subscribe('error', onError.bind(self));
      self[FIELDS.systems].push(system);
    });

    return this;
  },

  /**
   * Initializes the engine and all registered systems.
   * @returns {Promise} The engine initialization.
   * @throws {ReferenceError} Will throw this error if invoked after the engine has been disposed.
   * @throws {Error} Will throw this error when the engine is not capable of being initialized.
   * @emits Engine#initialize
   * @memberof Engine
   * @instance
   */
  init() {
    assertDisposable(this);
    const state = this[FIELDS.state];
    const manager = this[FIELDS.manager];
    const systems = this[FIELDS.systems];

    if (state === STATE.PENDING) {
      const initializers = [];

      forEach(systems, (system) => {
        initializers.push(system.init(manager));
      });

      return Promise.all(initializers)
        .then(() => {
          this[FIELDS.state] = STATE.INITIALIZED;
          this[FIELDS.emitter].emit('initialize');
        });
    }

    return Promise.reject(new Error('ECS can no longer be initialized.'));
  },

  /**
   * Starts the engine loop.
   * @returns {Promise} The engine start.
   * @throws {ReferenceError} Will throw this error if invoked after the engine has been disposed.
   * @throws {Error} Will throw this error when the engine is not capable of being started.
   * @emits Engine#start
   * @emits Engine#idle
   * @emits Engine#stop
   * @emits Engine#tick
   * @memberof Engine
   * @instance
   */
  start() {
    assertDisposable(this);
    const state = this[FIELDS.state];
    const systems = this[FIELDS.systems];
    const emitter = this[FIELDS.emitter];

    if (state === STATE.INITIALIZED || state === STATE.STOPPED) {
      const interval = this[FIELDS.interval];
      const context = {};
      set(context, 'entityService.get', this[FIELDS.manager].getEntities);
      set(context, 'entityService.find', this[FIELDS.manager].findEntities);
      set(context, 'entityService.remove', this[FIELDS.manager].removeEntities);
      set(context, 'entityService.length', this[FIELDS.manager].length);
      set(context, 'entityFactory.create', createEntity.bind(this));
      set(context, 'componentFactory.create', createEntity.bind(this));

      this[FIELDS.process] = setInterval(() => {
        Promise.resolve(this[FIELDS.state] = STATE.RUNNING)
          .then(() => Promise.each(systems, (system) => system.execute(context)))
          .then(() => {
            emitter.emit('tick');
            if (this[FIELDS.state] === STATE.RUNNING) {
              this[FIELDS.state] = STATE.IDLE;
              this[FIELDS.emitter].emit('idle');
            } else if (this[FIELDS.state] === STATE.STOPPING) {
              this[FIELDS.state] = STATE.STOPPED;
              this[FIELDS.emitter].emit('stop');
            }
          });
      }, interval);

      this[FIELDS.state] = STATE.RUNNING;
      this[FIELDS.emitter].emit('start');

      return Promise.resolve();
    }

    return Promise.reject(new Error('ECS cannot be started.'));
  },

  /**
   * Stops the engine loop.
   * @returns {Promise} The engine stop.
   * @throws {ReferenceError} Will throw this error if invoked after the engine has been disposed.
   * @throws {Error} Will throw this error when the engine is not capable of being stopped.
   * @emits Engine#stop
   * @memberof Engine
   * @instance
   */
  stop() {
    assertDisposable(this);
    return new Promise((resolve, reject) => {
      if (this[FIELDS.state] === STATE.RUNNING) {
        this[FIELDS.state] = STATE.STOPPING;
        clearInterval(this[FIELDS.process]);
        resolve();
      } else if (this[FIELDS.state] === STATE.IDLE) {
        clearInterval(this[FIELDS.process]);
        this[FIELDS.state] = STATE.STOPPED;
        this[FIELDS.emitter].emit('stop');
        resolve();
      }

      reject(new Error('ECS cannot be stopped.'));
    });
  },

});

export default Engine;