Source: controller/Application.js

'use strict';

var assert = function (condition, message) {
  if (!condition) {
    throw message || 'assertion failed';
  }
};

var emptyFn = function () {};
var extend = require('extend');
var Stats = require('T3.Stats');
var dat = require('T3.dat');
var THREE = require('THREE');

var Coordinates = require('../model/Coordinates');
var Keyboard = require('./Keyboard');
var LoopManager = require('./LoopManager');
var THREEx = require('../lib/THREEx/');
/**
 * @module controller/Application
 */

/**
 * Each instance controls one element of the DOM, besides creating
 * the canvas for the three.js app it creates a dat.gui instance
 * (to control objects of the app with a gui) and a Stats instance
 * (to view the current framerate)
 *
 * @class
 * @param {Object} config An object containing the following:
 * @param {string} [config.id=null] The id of the DOM element to inject the elements to
 * @param {number} [config.width=window.innerWidth]
 * The width of the canvas
 * @param {number} [config.height=window.innerHeight]
 * The height of the canvas
 * @param {boolean} [config.renderImmediately=true]
 * False to disable the game loop when the application starts, if
 * you want to resume the loop call `application.loopManager.start()`
 * @param {boolean} [config.injectCache=false]
 * True to add a wrapper over `THREE.Object3D.prototype.add` and
 * `THREE.Object3D.prototype.remove` so that it catches the last element
 * and perform additional operations over it, with this mechanism
 * we allow the application to have an internal cache of the elements of
 * the application
 * @param {boolean} [config.fullScreen=false]
 * True to make this app fullscreen adding additional support for
 * window resize events
 * @param {string} [config.theme='dark']
 * Theme used in the default scene, it can be `light` or `dark`
 * @param {object} [config.ambientConfig={}]
 * Additional configuration for the ambient, see the class {@link
 * Coordinates}
 * @param {object} [config.defaultSceneConfig={}] Additional config
 * for the default scene created for this world
 */
function Application(config) {
  config = extend({
    selector: null,
    width: window.innerWidth,
    height: window.innerHeight,
    renderImmediately: true,
    injectCache: false,
    fullScreen: false,
    theme: 'dark',
    helpersConfig: {},
    defaultSceneConfig: {
      fog: true
    }
  }, config);

  this.initialConfig = config;

  /**
   * Scenes in this world, each scene should be mapped with
   * a unique id
   * @type {Object}
   */
  this.scenes = {};

  /**
   * The active scene of this world
   * @type {THREE.Scene}
   */
  this.activeScene = null;

  /**
   * Reference to the cameras used in this world
   * @type {Array}
   */
  this.cameras = {};

  /**
   * The world can have many cameras, so the this is a reference to
   * the active camera that's being used right now
   * @type {T3.model.Camera}
   */
  this.activeCamera = null;

  /**
   * THREE Renderer
   * @type {Object}
   */
  this.renderer = null;

  /**
   * Keyboard manager
   * @type {Object}
   */
  this.keyboard = null;

  /**
   * Dat gui manager
   * @type {Object}
   */
  this.datgui = null;

  /**
   * Reference to the Stats instance (needed to call update
   * on the method {@link module:controller/Application#update})
   * @type {Object}
   */
  this.stats = null;

  /**
   * Reference to the local loop manager
   * @type {LoopManager}
   */
  this.loopManager = null;

  /**
   * Colors for the default scene
   * @type {Object}
   */
  this.theme = null;

  /**
   * Application cache
   * @type {Object}
   */
  this.__t3cache__ = {};

  Application.prototype.initApplication.call(this);
}

/**
 * Getter for the initial config
 * @return {Object}
 */
Application.prototype.getConfig = function () {
  return this.initialConfig;
};

/**
 * Bootstrap the application with the following steps:
 *
 * - Enabling cache injection
 * - Set the theme
 * - Create the renderer, default scene, default camera, some random lights
 * - Initializes dat.gui, Stats, a mask when the application is paised
 * - Initializes fullScreen events, keyboard and some helper objects
 * - Calls the game loop
 *
 */
Application.prototype.initApplication = function () {
  var me = this,
    config = me.getConfig();

  me.injectCache(config.injectCache);

  // theme
  me.setTheme(config.theme);

  // defaults
  me.createDefaultRenderer();
  me.createDefaultScene();
  me.createDefaultCamera();
  me.createDefaultLights();

  // utils
  me.initDatGui();
  me.initStats();
  me.initMask()
    .maskVisible(!config.renderImmediately);
  me.initFullScreen();
  me.initKeyboard();
  me.initCoordinates();

  // game loop
  me.gameLoop();
};

/**
 * Sets the active scene (it must be a registered scene registered
 * with {@link #addScene})
 * @param {string} key The string which was used to register the scene
 * @return {this}
 */
Application.prototype.setActiveScene = function (key) {
  this.activeScene = this.scenes[key];
  return this;
};

/**
 * Adds a scene to the scene pool
 * @param {THREE.Scene} scene
 * @param {string} key
 * @return {this}
 */
Application.prototype.addScene = function (scene, key) {
  console.assert(scene instanceof THREE.Scene);
  this.scenes[key] = scene;
  return this;
};

/**
 * Creates a scene called 'default' and sets it as the active one
 * @return {this}
 */
Application.prototype.createDefaultScene = function () {
  var me = this,
    config = me.getConfig(),
    defaultScene = new THREE.Scene();
  if (config.defaultSceneConfig.fog) {
    defaultScene.fog = new THREE.Fog(me.theme.fogColor, 2000, 4000);
  }
  me
    .addScene(defaultScene, 'default')
    .setActiveScene('default');
  return me;
};

/**
 * Creates the default THREE.WebGLRenderer used in the world
 * @return {this}
 */
Application.prototype.createDefaultRenderer = function () {
  var me = this,
    config = me.getConfig();
  var renderer = new THREE.WebGLRenderer({
//      antialias: true
  });
  renderer.setClearColor(me.theme.clearColor, 1);
  renderer.setSize(config.width, config.height);
  document
    .querySelector(config.selector)
    .appendChild(renderer.domElement);
  me.renderer = renderer;
  return me;
};

Application.prototype.setActiveCamera = function (key) {
  this.activeCamera = this.cameras[key];
  return this;
};

Application.prototype.addCamera = function (camera, key) {
  console.assert(camera instanceof THREE.PerspectiveCamera ||
    camera instanceof THREE.OrthographicCamera);
  this.cameras[key] = camera;
  return this;
};

/**
 * Create the default camera used in this world which is
 * a `THREE.PerspectiveCamera`, it also adds orbit controls
 * by calling {@link #createCameraControls}
 */
Application.prototype.createDefaultCamera = function () {
  var me = this,
    config = me.getConfig(),
    width = config.width,
    height = config.height,
    defaults = {
      fov: 38,
      ratio: width / height,
      near: 1,
      far: 10000
    },
    defaultCamera;

  defaultCamera = new THREE.PerspectiveCamera(
    defaults.fov,
    defaults.ratio,
    defaults.near,
    defaults.far
  );
  defaultCamera.position.set(500, 300, 500);

  // transparently support window resize
  if (config.fullScreen) {
    THREEx.WindowResize.bind(me.renderer, defaultCamera);
  }

  me
    .createCameraControls(defaultCamera)
    .addCamera(defaultCamera, 'default')
    .setActiveCamera('default');

  return me;
};

/**
 * Creates OrbitControls over the `camera` passed as param
 * @param  {THREE.PerspectiveCamera|THREE.OrtographicCamera} camera
 * @return {this}
 */
Application.prototype.createCameraControls = function (camera) {
  var me = this;
  camera.cameraControls = new THREE.OrbitControls(
    camera,
    me.renderer.domElement
  );
  // avoid panning to see the bottom face
  //camera.cameraControls.maxPolarAngle = Math.PI / 2 * 0.99;
  //camera.cameraControls.target.set(100, 100, 100);
  camera.cameraControls.target.set(0, 0, 0);
  return me;
};

/**
 * Creates some random lights in the default scene
 * @return {this}
 */
Application.prototype.createDefaultLights = function () {
  var light,
      scene = this.scenes['default'];

  light = new THREE.AmbientLight(0x222222);
  scene.add(light).cache('ambient-light-1');

  light = new THREE.DirectionalLight( 0xFFFFFF, 1.0 );
  light.position.set( 200, 400, 500 );
  scene.add(light).cache('directional-light-1');

  light = new THREE.DirectionalLight( 0xFFFFFF, 1.0 );
  light.position.set( -500, 250, -200 );
  scene.add(light).cache('directional-light-2');

  return this;
};

/**
 * Sets the theme to be used in the default scene
 * @param {string} name Either the string `dark` or `light`
 * @todo Make the theme system extensible
 * @return {this}
 */
Application.prototype.setTheme = function (name) {
  var me = this,
    themes = require('../').themes;
  assert(themes[name]);
  me.theme = themes[name];
  return me;
};

/**
 * Creates a mask on top of the canvas when it's paused
 * @return {this}
 */
Application.prototype.initMask = function () {
  var me = this,
    config = me.getConfig(),
    mask,
    hidden;
  mask = document.createElement('div');
  mask.className = 't3-mask';
  // mask.style.display = 'none';
  mask.style.position = 'absolute';
  mask.style.top = '0px';
  mask.style.width = '100%';
  mask.style.height = '100%';
  mask.style.background = 'rgba(0,0,0,0.5)';

  document
    .querySelector(config.selector)
    .appendChild(mask);

  me.mask = mask;
  return me;
};

/**
 * Updates the mask visibility
 * @param  {boolean} v True to make it visible
 */
Application.prototype.maskVisible = function (v) {
  var mask = this.mask;
  mask.style.display = v ? 'block' : 'none';
};

/**
 * Inits the dat.gui helper which is placed under the
 * DOM element identified by the initial configuration selector
 * @return {this}
 */
Application.prototype.initDatGui = function () {
  var me = this,
    config = me.getConfig(),
    gui = new dat.GUI({
      autoPlace: false
    });

  extend(gui.domElement.style, {
    position: 'absolute',
    top: '0px',
    right: '0px',
    zIndex: '1'
  });
  document
    .querySelector(config.selector)
    .appendChild(gui.domElement);
  me.datgui = gui;
  return me;
};

/**
 * Init the Stats helper which is placed under the
 * DOM element identified by the initial configuration selector
 * @return {this}
 */
Application.prototype.initStats = function () {
  var me = this,
    config = me.getConfig(),
    stats;
  // add Stats.js - https://github.com/mrdoob/stats.js
  stats = new Stats();
  extend(stats.domElement.style, {
    position: 'absolute',
    zIndex: 1,
    bottom: '0px'
  });
  document
    .querySelector(config.selector)
    .appendChild( stats.domElement );
  me.stats = stats;
  return me;
};

/**
 * Binds the F key to make a world go full screen
 * @todo This should be used only when the canvas is active
 */
Application.prototype.initFullScreen = function () {
  var config = this.getConfig();
  // allow 'f' to go fullscreen where this feature is supported
  if(config.fullScreen && THREEx.FullScreen.available()) {
    THREEx.FullScreen.bindKey();
  }
};

/**
 * Initializes the coordinate helper (its wrapped in a model in T3)
 */
Application.prototype.initCoordinates = function () {
  var config = this.getConfig();
  this.scenes['default']
    .add(
      new Coordinates(config.helpersConfig, this.theme)
        .initDatGui(this.datgui)
        .mesh
    );
};

/**
 * Initis the keyboard helper
 * @return {this}
 */
Application.prototype.initKeyboard = function () {
  this.keyboard = new Keyboard();
  return this;
};

/**
 * Initializes the game loop by creating an instance of {@link LoopManager}
 * @return {this}
 */
Application.prototype.gameLoop = function () {
  var config = this.getConfig();
  this.loopManager = new LoopManager(this, config.renderImmediately)
    .initDatGui(this.datgui)
    .animate();
  return this;
};

/**
 * Update phase, the world updates by default:
 *
 * - The stats helper
 * - The camera controls if the active camera has one
 *
 * @param {number} delta The number of seconds elapsed
 */
Application.prototype.update = function (delta) {
  var me = this;

  // stats helper
  me.stats.update();

  // camera update
  if (me.activeCamera.cameraControls) {
    me.activeCamera.cameraControls.update(delta);
  }
};

/**
 * Render phase, calls `this.renderer` with `this.activeScene` and
 * `this.activeCamera`
 */
Application.prototype.render = function () {
  var me = this;
  me.renderer.render(
    me.activeScene,
    me.activeCamera
  );
};

/**
 * Wraps `THREE.Object3D.prototype.add` and `THREE.Object3D.prototype.remove`
 * with functions that save the last object which `add` or `remove` have been
 * called with, this allows to call the method `cache` which will cache
 * the object with an identifier allowing fast object retrieval
 *
 * @example
 *
 *   var instance = t3.Application.run({
 *     injectCache: true,
 *     init: function () {
 *       var group = new THREE.Object3D();
 *       var innerGroup = new THREE.Object3D();
 *
 *       var geometry = new THREE.BoxGeometry(1,1,1);
 *       var material = new THREE.MeshBasicMaterial({color: 0x00ff00});
 *       var cube = new THREE.Mesh(geometry, material);
 *
 *       innerGroup
 *         .add(cube)
 *         .cache('myCube');
 *
 *       group
 *         .add(innerGroup)
 *         .cache('innerGroup');
 *
 *       // removal example
 *       // group
 *       //   .remove(innerGroup)
 *       //   .cache();
 *
 *       this.activeScene
 *         .add(group)
 *         .cache('group');
 *     },
 *
 *     update: function (delta) {
 *       var cube = this.getFromCache('myCube');
 *       // perform operations on the cube
 *     }
 *   });
 *
 * @param  {boolean} inject True to enable this behavior
 */
Application.prototype.injectCache = function (inject) {
  var me = this,
      lastObject,
      lastMethod,
      add = THREE.Object3D.prototype.add,
      remove = THREE.Object3D.prototype.remove,
      cache = this.__t3cache__;

  if (inject) {
    THREE.Object3D.prototype.add = function (object) {
      lastMethod = 'add';
      lastObject = object;
      return add.apply(this, arguments);
    };

    THREE.Object3D.prototype.remove = function (object) {
      lastMethod = 'remove';
      lastObject = object;
      return remove.apply(this, arguments);
    };

    THREE.Object3D.prototype.cache = function (name) {
      assert(lastObject, 'T3.Application.prototype.cache: this method' +
        ' needs a previous call to add/remove');
      if (lastMethod === 'add') {
        lastObject.name = lastObject.name || name;
        assert(lastObject.name);
        cache[lastObject.name] = lastObject;
      } else {
        assert(lastObject.name);
        delete cache[lastObject.name];
      }
      lastObject = null;
      return me;
    };
  } else {
    THREE.Object3D.prototype.cache = function () {
      return this;
    };
  }
};

/**
 * Gets an object from the cache if `injectCache` was enabled and
 * an object was registered with {@link #cache}
 * @param  {string} name
 * @return {THREE.Object3D}
 */
Application.prototype.getFromCache = function (name) {
  return this.__t3cache__[name];
};

/**
 * @static
 * Creates a subclass of Application whose instances don't need to
 * worry about the inheritance process
 * @param  {Object} options The same object passed to the {@link Application}
 * constructor
 * @param {Object} options.init Initialization phase, function called in
 * the constructor of the subclass
 * @param {Object} options.update Update phase, function called as the
 * update function of the subclass, it also calls Application's update
 * @return {t3.QuickLaunch} An instance of the subclass created in
 * this function
 */
Application.run = function (options) {
  options.init = options.init || emptyFn;
  options.update = options.update || emptyFn;
  assert(options.selector, 'canvas selector required');

  var QuickLaunch = function (options) {
    Application.call(this, options);
    options.init.call(this, options);
  };
  QuickLaunch.prototype = Object.create(Application.prototype);

  QuickLaunch.prototype.update = function (delta) {
    Application.prototype.update.call(this, delta);
    options.update.call(this, delta);
  };

  return new QuickLaunch(options);
};

module.exports = Application;