Object3D.js

const packageJSON = require('@ud-viz/game_shared/package.json');
const ExternalScript = require('./component/ExternalScript');
const GameScript = require('./component/GameScript');
const Collider = require('./component/Collider');
const Audio = require('./component/Audio');
const Render = require('./component/Render');

const THREE = require('three');
const { objectParse } = require('@ud-viz/utils_shared');

const Object3D = class extends THREE.Object3D {
  /**
   * Base class extended {@link THREE.Object3D} to compose 3D scene of ud-viz game
   *
   * @param {object} json - json to configure the object3D
   * @param {string=} json.uuid - uuid
   * @param {string=} json.parentUUID - uuid of this parent object3D
   * @param {object=} json.userData - userData
   * @param {string} [json.name=""] - name
   * @param {boolean} [json.static=false] - static
   * @param {boolean} [json.outdated=false] - outdated
   * @param {boolean} [json.visble=true] - visible
   * @param {boolean} [json.gameContextUpdate=true] - should be update from the game context
   * @param {Array<string>} [json.forceToJSONComponent=[]] - force certain component to be export in json
   * @param {Object<string,object>} [json.components={}] - components {@link Component}
   * @param {Array} [json.matrix] - matrix
   * @param {object[]} [json.children] - json of children of object3D
   */
  constructor(json) {
    super();

    /**
     * tag to make difference between this and THREE.Object3D
     *
     * @type {boolean}
     */
    this.isGameObject3D = true;

    // default Game.Object3D Euler order
    this.rotation.reorder('ZXY');

    json = Object3D.parseJSON(json);

    if (json.uuid != undefined) {
      /**
       * uuid of object3D
       *
       * @type {string}
       */
      this.uuid = json.uuid;
    }

    /**
     * uuid of the parent object3D
     *
     * @type {string|null}
     */
    this.parentUUID = json.parentUUID || null;

    /**
     *  user data
     *  
     @type {object} */
    this.userData = json.userData || {};

    /**
     * name of object3D
     *  
      @type {string}*/
    this.name = json.name || '';

    /** @type {boolean} */
    this.visible = json.visible != undefined ? json.visible : true;

    /**
     * true if the object3D is not going to move in space
     *
     * @type {boolean}
     */
    this.static = json.static || false;
    /**
     * {@link https://threejs.org/docs/#manual/en/introduction/How-to-update-things}
     *
     * @type {boolean}
     */
    this.matrixAutoUpdate = !this.static;

    /**
     * true if object3D model has changed
     *
     * @type {boolean}
     */
    this.outdated = json.outdated || false;

    /**
     * true if object3D should consider game context update
     *
     * @type {boolean}
     */
    this.gameContextUpdate = true;
    if (json.gameContextUpdate != undefined) {
      this.gameContextUpdate = json.gameContextUpdate;
    }

    /**
     * force certain component to be export in json
     *
     * @type {string[]}
     */
    this.forceToJSONComponent = json.forceToJSONComponent || [];

    /** @type {Object<string,object>} */
    this.components = {};
    this.updateComponentFromJSON(json.components);
    this.updateMatrixFromJSON(json.matrix);

    if (json.children) {
      json.children.forEach((childJSON) => {
        this.add(new Object3D(childJSON));
      });
    }
  }

  /**
   * @callback Object3DCondition
   * @param {Object3D} object - object3d to test
   * @returns {boolean} - true object3d match condition
   */

  /**
   *
   * @param {Object3DCondition} condition - condition to test
   * @returns {Object3D} - return first object3D in hierarchy matching condition
   */
  getFirst(condition) {
    let result = null;
    this.traverse((child) => {
      if (condition(child)) {
        result = child;
        return true;
      }
      return false;
    });
    return result;
  }

  /**
   *
   * @param {Object3DCondition} condition - condition to test
   * @returns {Object3D} - return an array of objects3D in hierarchy matching condition
   */
  filter(condition) {
    const result = [];
    this.traverse((child) => {
      if (condition(child)) {
        result.push(child);
      }
    });
    return result;
  }

  /**
   *
   * @returns {boolean} - true if this has game context update
   */
  hasGameContextUpdate() {
    return this.gameContextUpdate;
  }

  /**
   *
   * @param {Array} jsonMatrix - array of the matrix
   * @returns {void}
   */
  updateMatrixFromJSON(jsonMatrix) {
    if (!jsonMatrix) return;
    this.matrix.fromArray(jsonMatrix);
    this.matrix.decompose(this.position, this.quaternion, this.scale);
  }

  /**
   *
   * @param {object} json - json to update from
   * @param {string} json.uuid - uuid
   * @param {Object<string,object>=} [json.components={}] - components {@link Component}
   * @param {Array=} [json.matrix] - matrix
   * @param {string} json.name - name
   * @param {boolean} json.static - static
   * @param {boolean} json.outdated - outdated
   * @param {object[]} json.children - json of children of object3D
   */
  updatefromJSON(json) {
    json = Object3D.parseJSON(json);

    this.uuid = json.uuid;

    this.components = {}; // Clear
    this.updateComponentFromJSON(json.components);
    this.updateMatrixFromJSON(json.matrix);
    this.name = json.name;
    this.static = json.static;
    this.visible = json.visible;
    this.outdated = json.outdated;

    this.children.forEach((child) => {
      let jsonChild;
      for (let i = 0; i < json.children.length; i++) {
        if (json.children[i].uuid == child.uuid) {
          jsonChild = json.children[i];
          break;
        }
      }
      if (!jsonChild) {
        // console.warn(child.name, ' no longer in ', this.name);
        return;
      }

      child.updatefromJSON(jsonChild);
    });
  }

  /**
   *
   * @param {Object<string,object>} componentsJSON - json components to update from
   */
  updateComponentFromJSON(componentsJSON) {
    if (!componentsJSON) {
      return;
    }

    for (const type in componentsJSON) {
      const componentModelJSON = componentsJSON[type];

      switch (type) {
        case Render.Component.TYPE:
          if (this.components[Render.Component.TYPE])
            console.warn('multiple component');

          this.components[Render.Component.TYPE] = new Render.Component(
            new Render.Model(componentModelJSON)
          );

          break;
        case Audio.Component.TYPE:
          if (this.components[Audio.Component.TYPE])
            console.warn('multiple component');

          this.components[Audio.Component.TYPE] = new Audio.Component(
            new Audio.Model(componentModelJSON)
          );

          break;
        case GameScript.Component.TYPE:
          if (this.components[GameScript.Component.TYPE])
            console.warn('multiple component');

          this.components[GameScript.Component.TYPE] = new GameScript.Component(
            new GameScript.Model(componentModelJSON)
          );

          break;
        case ExternalScript.Component.TYPE:
          if (this.components[ExternalScript.Component.TYPE])
            console.warn('multiple component');

          this.components[ExternalScript.Component.TYPE] =
            new ExternalScript.Component(
              new ExternalScript.Model(componentModelJSON)
            );

          break;
        case Collider.Component.TYPE:
          if (this.components[Collider.Component.TYPE])
            console.warn('multiple component');

          this.components[Collider.Component.TYPE] = new Collider.Component(
            new Collider.Model(componentModelJSON)
          );

          break;
        default:
          throw new Error('wrong type component ' + type);
      }
    }
  }

  /**
   *
   * @param {boolean} value - true if object3D is outdated (model has changed)
   */
  setOutdated(value) {
    this.outdated = value;
  }

  /**
   *
   * @returns {boolean} - true if object3D is outdated (model has changed)
   */
  isOutdated() {
    return this.outdated;
  }

  /**
   *
   * @returns {boolean} - true if object3D is static
   */
  isStatic() {
    return this.static;
  }

  /**
   *
   * @returns {Object<string,object>} - components of object3D @see Component
   */
  getComponents() {
    return this.components;
  }

  /**
   *
   * @param {string} type - type of the component
   * @returns {object} - component of type
   */
  getComponent(type) {
    return this.components[type];
  }

  /**
   *
   * @returns {Object3D} - clone of object3D
   */
  clone() {
    return new Object3D(this.toJSON());
  }

  /**
   * @callback TraverseCallback
   * @param {Object3D} object3D - the object3D traversed
   */

  /**
   * Apply a callback to object3D and its children recursively like {@link THREE.Object3D}
   * This is not exactly the same one since there is the possibility to stop the traverse
   * and the possibility to remove an object3D while parent is traversed TODO: object should not be remove though since its cause of bug !!! (maybe test with a descendant for loop (i--))
   *
   * @param {TraverseCallback} cb - callback to apply to object3D and its children recursively
   * @returns {boolean} - true when traverse should be stop
   */
  traverse(cb) {
    if (cb(this)) return true;

    for (let index = 0; index < this.children.length; index++) {
      const element = this.children[index];
      if (element.traverse(cb)) return true;
    }

    return false;
  }

  /**
   *
   * @param {boolean=} [full=true] - component with controllers should be added to the result
   * @param {boolean=} [withMetadata=false] - add metadata to the result
   * @param {boolean=} [withChildren=true] - add children to the result
   * @returns {object} - object of the object3D if withMetatdata = false, otherwise the object is store in result.object and metadata in result.metadata
   */
  toJSON(full = true, withMetadata = false, withChildren = true) {
    const result = {};

    result.children = [];
    if (withChildren) {
      this.children.forEach((child) => {
        if (!child.isGameObject3D) return;
        result.children.push(child.toJSON(full, false));
      });
    }

    // update matrix
    this.updateMatrix();
    result.matrix = this.matrix.toArray();

    // other attributes
    result.uuid = this.uuid;
    result.name = this.name;
    result.static = this.static;
    result.outdated = this.outdated;
    result.visible = this.visible;
    result.gameContextUpdate = this.gameContextUpdate;
    if (this.parent) {
      result.parentUUID = this.parent.uuid;
    }
    result.userData = this.userData;

    // add components
    result.components = {};
    for (const type in this.components) {
      const c = this.components[type];

      if (!full && c.getController()) {
        continue;
      }

      result.components[type] = c.getModel().toJSON();
    }

    // Add to json the forced component
    result.forceToJSONComponent = this.forceToJSONComponent;
    for (let index = 0; index < this.forceToJSONComponent.length; index++) {
      const type = this.forceToJSONComponent[index];
      const c = this.components[type];
      result.components[type] = c.getModel().toJSON();
    }

    if (withMetadata) {
      // metadata to know this is method which compute the JSON
      return {
        metadata: {
          type: Object3D.name,
          method: this.toJSON.name,
          version: packageJSON.version,
          packageName: packageJSON.name,
        },
        object: result,
      };
    }

    return result;
  }
};

/**
 * If json has metadata update object of object3D if not nothing is done
 *
 * @param {object} json - json of object3D
 * @returns {object} - json object of object3D
 */
Object3D.parseJSON = function (json) {
  if (!json) {
    console.error(json);
    throw new Error('no json');
  }

  // check if object
  if (json.object) {
    if (!json.metadata) {
      console.info('no metadata');
    }
    // metadata should be in json.metadata
    // do stuff like versioning then return the object
    return json.object;
  }
  return json;
};

/**
 *
 * @returns {THREE.Vector3} - Default forward of Object3D
 */
Object3D.DefaultForward = function () {
  return new THREE.Vector3(0, 1, 0);
};

/**
 *
 * @returns {THREE.Vector3} - Default up vector of Object3D
 */
Object3D.DefaultUp = function () {
  return new THREE.Vector3(0, 0, 1);
};

/**
 *
 * @param {Object3D} object3D - object3D to compute forward vector
 * @returns {THREE.Vector3} - forward vector of object3D
 */
Object3D.computeForward = function (object3D) {
  return Object3D.DefaultForward().applyQuaternion(object3D.quaternion);
};

/**
 *
 * @param {Object3D} object3D - object3D to compute backward vector
 * @returns {THREE.Vector3} - backward vector of object3D
 */
Object3D.computeBackward = function (object3D) {
  return Object3D.computeForward(object3D).negate();
};

/**
 *
 * @param {Object3D} object3D - object3D to compute up vector
 * @returns {THREE.Vector3} - up vector of object3D
 */
Object3D.computeUp = function (object3D) {
  return Object3D.DefaultUp().applyQuaternion(object3D.quaternion);
};

/**
 *
 * @param {Object3D} object3D - object3D to compute down vector
 * @returns {THREE.Vector3} - down vector of object3D
 */
Object3D.computeDown = function (object3D) {
  return Object3D.computeUp(object3D).negate();
};

/**
 * Return a deep copy of object3D (uuids and name are regenerated)
 *
 * @param {Object3D} object3D - object3D to deep copy
 * @returns {Object3D} - deep copy of object3D
 */
Object3D.deepCopy = function (object3D) {
  const cloneJSON = object3D.toJSON(true);
  // Rename uuid
  objectParse(cloneJSON, function (json, key) {
    const keyLowerCase = key.toLowerCase();
    if (keyLowerCase === 'uuid') json[key] = THREE.MathUtils.generateUUID();

    if (keyLowerCase === 'name') {
      json[key] = 'Clone of ' + json[key];
    }
  });
  return new Object3D(cloneJSON);
};

/**
 *
 * Parse parent object till finding a gameobject
 *
 * @param {THREE.Object3D} object - object to fetch into
 * @returns {Object3D} - first gameobject found in hierarchy
 */
Object3D.fetchFirstGameObject3D = function (object) {
  if (object.isGameObject3D) return object;

  let result = object;
  while (result && !result.isGameObject3D) {
    result = result.parent;
  }
  return result;
};

module.exports = Object3D;