Engine.js

import { compose } from 'stampit';
import isStamp from 'stampit/isStamp';
import FastArray from 'fast-array';

import EventEmitter from './EventEmitter';
import Pool from './Pool';
import { isNonEmptyString } from './helpers';

/**
 * This module manages the state of entities, components and systems. The heart of Entropy.
 *
 * @class Engine
 * @extends EventEmitter
 */
const Engine = compose({
  init(opts) {
    // entity ids start from 1, 0 means uninitailized or disabled entity
    let greatestEntityID = 1;

    /**
     * When entity is removed, it's ID can be reused by new entities. This pool stores old IDs ready to reuse.
     *
     * @private
     * @name _entitiesIdsPool
     * @memberof Engine#
     * @type Pool
     */
    this._entitiesIdsPool = Pool({
      _new() {
        return greatestEntityID++;
      },
    });

    /**
     * Systems that are processed every tick.
     *
     * @private
     * @name _systems
     * @memberof Engine#
     * @type FastArray
     */
    this._systems = FastArray({
      initialSize: 10,
    });

    /**
     * Array with entities. Array index corresponds to ID of an entity.
     * First element is empty (equals 0), because entity IDs start from 1.
     * Entity with `id` property equal 0 is _officially_ not present
     * in the system (it can be for example present in the pool or waiting
     * for addition to system).
     *
     * @private
     * @name _entities
     * @memberof Engine#
     * @type FastArray
     */
    this._entities = FastArray();

    /**
     * List of modified entities.
     *
     * When entity is modified it is added to this list. After each frame modifications are applied to every entity on the list.
     *
     * @private
     * @name _modifiedEntities
     * @memberof Engine#
     * @type FastArray
     */
    this._modifiedEntities = FastArray();

    /**
     * Queue of entities ready to be added on next tick.
     *
     * @private
     * @name _entitiesToAdd
     * @memberof Engine#
     * @type FastArray
     */
    this._entitiesToAdd = FastArray();

    /**
     * Queue of entities ready to be removed on next tick.
     *
     * @private
     * @name _entitiesToRemove
     * @memberof Engine#
     * @type FastArray
     */
    this._entitiesToRemove = FastArray();

    /**
     * Queue of systems ready to be added on next tick.
     *
     * @private
     * @name _systemsToAdd
     * @memberof Engine#
     * @type FastArray
     */
    this._systemsToAdd = FastArray({
      initialSize: 10,
    });

    /**
     * Queue of systems ready to be removed on next tick.
     *
     * @private
     * @name _systemsToRemove
     * @memberof Engine#
     * @type FastArray
     */
    this._systemsToRemove = FastArray({
      initialSize: 10,
    });

    /**
     * Array of queries. Every query that was used is stored here and updated when engine state changes.
     *
     * @private
     * @name _queries
     * @memberof Engine#
     * @type {Array}
     */
    this._queries = [];

    /**
     * Current number of entities active.
     *
     * @private
     * @name _entitiesCount
     * @memberof Engine#
     * @type {Number}
     */
    this._entitiesCount = 0;

    /**
     * Indicates whether clearing is scheduled.
     *
     * @private
     * @name _isClearingScheduled
     * @memberof Engine#
     * @type {Boolean}
     */
    this._isClearingScheduled = false;

    /**
     * Indicates whether clearing was performed.
     *
     * @private
     * @name _wasClearingPerformed
     * @memberof Engine#
     * @type {Boolean}
     */
    this._wasClearingPerformed = false;

    this.game = opts.game;
  },
  methods: {
    /**
     * Adds entity to adding queue.
     * If entity is new (not recycled), adds event listener for modifications.
     *
     * @memberof Engine#
     * @param {Entity} entity entity to add
     */
    addEntity(entity) {
      if (!entity.isRecycled()) {
        entity.on('queueModification', () => {
          this._markModifiedEntity(entity);
        });
      }

      if (this.game.isRunning()) {
        this._entitiesToAdd.push(entity);
      } else {
        this._addEntity(entity);
      }
    },
    /**
     * Adds entity to removing queue.
     *
     * @memberof Engine#
     * @param {Entity} entity entity to remove
     */
    removeEntity(entity) {
      if (this.game.isRunning()) {
        this._entitiesToRemove.push(entity);
      } else {
        this._removeEntity();
      }
    },
    /**
     * Adds system to adding queue.
     *
     * @memberof Engine#
     * @param {System} system to add
     */
    addSystem(system) {
      this._systemsToAdd.push(system);
    },
    /**
     * Adds system to removing queue.
     *
     * @memberof Engine#
     * @param {String|System} systemOrType system instance or system type to remove
     */
    removeSystem(systemOrType) {
      let system;

      if (isStamp(systemOrType)) {
        system = systemOrType;
      } else if (isNonEmptyString(systemOrType)) {
        system = this._systems.find(s => s.type === systemOrType);
      }

      if (system) {
        this._systemsToRemove.push(system);
      }
    },
    /**
     * Gets entities matching query criterions.
     *
     * @memberof Engine#
     * @param {Query} query query
     * @return {Object} object with `entities` and `length` properties
     */
    getEntities(query) {
      if (this._queries.indexOf(query) === -1) {
        this._initializeQuery(query);
      }

      return query.getEntities();
    },
    /**
     * Updates the engine:
     * - updates systems (calls `onUpdate` method of every active system)
     * - performs clearing, if scheduled
     * - applies engine modifications (adding/removing entities/systems, updating queries)
     *
     * @memberof Engine#
     * @fires Engine#clear
     * @param {...Any} args arguments passed to systems `onUpdate` methods
     */
    update(...args) {
      this._updateSystems(...args);

      if (this._isClearingScheduled) {
        this._performScheduledClearing();
      }

      this._removeEntities();
      this._addEntities();
      this._modifyEntities();
      this._removeSystems();
      this._addSystems();
      this._updateQueries();

      if (this._wasClearingPerformed) {
        /**
         * Engine was cleared.
         *
         * @event Engine#clear
         */
        this.emit('clear');

        this._wasClearingPerformed = false;
        this._isClearingScheduled = false;
      }
    },
    /**
     * Schedules clearing. Clearing is done on next frame.
     *
     * @memberof Engine#
     */
    clear() {
      this._isClearingScheduled = true;
    },
    _markModifiedEntity(entity) {
      if (entity.id !== 0 && this._modifiedEntities.indexOf(entity) === -1) {
        if (this.game.isRunning()) {
          this._modifiedEntities.push(entity);
        } else {
          this._modifyEntity(entity);
        }
      }
    },
    _updateSystems(...args) {
      for (let i = 0; i < this._systems.length; i += 1) {
        const system = this._systems.arr[i];

        if (!system._disabled) {
          system.onUpdate(...args);
        }
      }
    },
    _removeEntities() {
      while (this._entitiesToRemove.length) {
        this._removeEntity(this._entitiesToRemove.pop());
      }
    },
    _removeEntity(entity) {
      if (entity.id === 0) {
        return;
      }

      for (let i = 0; i < this._queries.length; i += 1) {
        const query = this._queries[i];

        if (query.satisfiedBy(entity)) {
          query.removeFromIndex(entity.id);
        }
      }

      entity.onRemove(entity);

      // remove entity from global index
      this._entities.unsetAtIndex(entity.id);

      // send unused entity ID to pool for later reuse
      this._entitiesIdsPool.free(entity.id);

      entity.removeAllComponents();

      // id = 0 indicates inactive entity
      entity.id = 0;

      this.game.entity.free(entity);

      this._entitiesCount -= 1;

      this.emit('entityRemove', entity);
    },
    _addEntities() {
      while (this._entitiesToAdd.length) {
        this._addEntity(this._entitiesToAdd.pop());
      }
    },
    _addEntity(entity) {
      const newEntityId = this._entitiesIdsPool.allocate();

      entity.id = newEntityId;

      this._entities.insertAtIndex(newEntityId, entity);

      for (let i = 0; i < this._queries.length; i += 1) {
        const query = this._queries[i];

        if (query.satisfiedBy(entity)) {
          query.addToIndex(newEntityId);
        }
      }

      this._entitiesCount += 1;

      this.emit('entityAdd', entity);
    },
    _modifyEntity(entity) {
      for (let i = 0; i < this._queries.length; i += 1) {
        const query = this._queries[i];

        const satisfiedBeforeModification = query.satisfiedBy(entity);

        entity.applyModifications();

        const satisfiesAfterModification = query.satisfiedBy(entity);

        if (!satisfiedBeforeModification && satisfiesAfterModification) {
          query.addToIndex(entity.id);
        } else if (satisfiedBeforeModification && !satisfiesAfterModification) {
          query.removeFromIndex(entity.id);
        }
      }
    },
    _modifyEntities() {
      while (this._modifiedEntities.length) {
        this._modifyEntity(this._modifiedEntities.pop());
      }
    },
    _addSystem(system) {
      let insertionIndex = 0;
      for (;insertionIndex < this._systems.length; insertionIndex += 1) {
        if (this._systems.arr[insertionIndex].priority > system.priority) {
          break;
        }
      }

      this._systems.insertBefore(insertionIndex, system);
    },
    _addSystems() {
      while (this._systemsToAdd.length) {
        this._addSystem(this._systemsToAdd.shift());
      }
    },
    _removeSystem(system) {
      const indexOfSystem = this._systems.indexOf(system);

      if (indexOfSystem !== -1) {
        system.onRemove();

        this._systems.removeAtIndex(indexOfSystem);
      }
    },
    _removeSystems() {
      while (this._systemsToRemove.length) {
        this._removeSystem(this._systemsToRemove.shift());
      }
    },
    _updateQueries() {
      for (let i = 0; i < this._queries.length; i += 1) {
        this._queries[i].update(this._entities);
      }
    },
    _performScheduledClearing() {
      this._wasClearingPerformed = true;
    },
    _initializeQuery(query) {
      query.initialize(this._entities);

      this._queries.push(query);
    },
  },
}, EventEmitter);

export default Engine;