component_Collider.js

const { polygon2DArea } = require('@ud-viz/utils_shared');
const { Component, Model, Controller } = require('./Component');

const { Circle, Polygon } = require('detect-collisions');
const { Euler, Vector3, Quaternion } = require('three');

// to avoid new operation
const _colliderPositionBuffer = new Vector3();
const _colliderQuaternion = new Quaternion();
const _colliderEuler = new Euler();
const _colliderScale = new Vector3();

/**
 * Collider object3D component, this component use {@link https://www.npmjs.com/package/detect-collisions}, note that collisions are handle in 2D
 *
 * @see module:Collider
 * @class
 */
const ColliderComponent = class extends Component {
  constructor(model) {
    super(model || new ColliderModel());
  }
};

ColliderComponent.TYPE = 'Collider';
ColliderComponent.SHAPE_TYPE = {
  CIRCLE: 'Circle',
  POLYGON: 'Polygon',
};

/**
 * @see module:Collider
 * @class
 */
const ColliderModel = class extends Model {
  /**
   * Model of a collider component
   *
   * @param {object} json - object to configure collider model
   * @param {string} json.uuid - uuid collider model
   * @param {Array<PolygonJSON|CircleJSON>} [json.shapes] - shapes of collisions
   * @param {boolean} json.body - if true this is a physics collisions
   * @todo body should be handle by context (meaning context move according the physic of the collision)
   */
  constructor(json = {}) {
    super(json);

    /**
     * shapes of collisions
     *
     * @type {Array<PolygonJSON|CircleJSON>}
     */
    this.shapesJSON = json.shapes || [];

    /**
     * if true this is a physics collision
     *
     * @type {boolean}
     */
    this.body = json.body || false;
  }

  /**
   *
   * @returns {boolean} - body of collider model
   */
  isBody() {
    return this.body;
  }

  /**
   *
   * @returns {Array<PolygonJSON|CircleJSON>} - shapes json of collider model
   */
  getShapesJSON() {
    return this.shapesJSON;
  }

  /**
   *
   * @returns {object} - export collider model to json object
   */
  toJSON() {
    return {
      uuid: this.uuid,
      type: ColliderModel.TYPE,
      shapes: this.shapesJSON,
      body: this.body,
    };
  }
};

/**
 * @see module:Collider
 * @class
 */
class ColliderController extends Controller {
  /**
   * Controller collider component
   *
   * @param {ColliderModel} model - model controller
   * @param {import("../Object3D").Object3D} object3D - object3D parent of this collider component
   */
  constructor(model, object3D) {
    super(model, object3D);

    /**
     * shapes wrapper {@link ShapeWrapper}
     *
     * @type {ShapeWrapper}
     */
    this.shapeWrappers = [];

    /**  initialize shape wrapper from model shapesJSON */
    this.model.getShapesJSON().forEach((shapeJSON) => {
      const wrapper = new ShapeWrapper(
        this.object3D,
        shapeJSON,
        this.model.isBody()
      );
      this.shapeWrappers.push(wrapper);
    });
  }

  /**
   * Update worldtransform of the shapeWrappers
   *
   * @param {Vector3} offset - offset of the collider system
   */
  update(offset) {
    this.object3D.updateMatrixWorld();
    this.object3D.matrixWorld.decompose(
      _colliderPositionBuffer,
      _colliderQuaternion,
      _colliderScale
    );

    // collision referential is offseted so detect-collision deals with small number
    _colliderPositionBuffer.sub(offset);

    this.shapeWrappers.forEach((b) => {
      b.update(
        _colliderPositionBuffer,
        _colliderEuler.setFromQuaternion(_colliderQuaternion),
        _colliderScale
      );
    });
  }

  /**
   *
   * @returns {ShapeWrapper[]} - shape wrappers of controller
   */
  getShapeWrappers() {
    return this.shapeWrappers;
  }
}

/**
 * @typedef {object} PolygonJSON - json object to configure {@link Polygon} of {@link https://www.npmjs.com/package/detect-collisions}
 * @property {string} type - to identify this is a Polygon must be equal to "Polygon"
 * @property {Array<{x,y}>} points - points of the polygon
 */

/**
 * @typedef {object} CircleJSON - json object to configure {@link Circle} of {@link https://www.npmjs.com/package/detect-collisions}
 * @property {string} type - to identify this is a Circle must be equal to "Circle"
 * @property {{x,y}} center - center of the circle
 * @property {number} radius - radius of the circle
 */

/**
 * @class
 */
class ShapeWrapper {
  /**
   * Wrap {@link Polygon} or {@link Circle} of {@link https://www.npmjs.com/package/detect-collisions}
   *
   * @param {object} object3D - object 3D parent of the controller collider
   * @param {PolygonJSON|CircleJSON} json - shapeJSON
   * @param {boolean} body - shape body
   */
  constructor(object3D, json, body) {
    /**
     * object3D parent of the controller collider
     *
     * @type {object}
     */
    this.object3D = object3D;

    /**
     * shape JSON
     *
     * @type {PolygonJSON|CircleJSON}
     */
    this.json = json;

    /** @type {boolean} */
    this.body = body; // TODO shape of detect have a isStatic attr

    /**
     * {@link Circle} or {@link Polygon} of {@link https://www.npmjs.com/package/detect-collisions}
     *
     * @type {Polygon|Circle}
     */
    this.shape = null;
    this.initShapeFromJSON(json);
  }

  /**
   *
   * @returns {boolean} - body
   */
  isBody() {
    return this.body;
  }

  /**
   *
   * @returns {Polygon|Circle} - shape of {@link https://www.npmjs.com/package/detect-collisions}
   */
  getShape() {
    return this.shape;
  }

  /**
   *
   * @returns {object} - object3D of shape wrapper
   */
  getObject3D() {
    return this.object3D;
  }

  /**
   * Initialize shape of {@link https://www.npmjs.com/package/detect-collisions} and update method then attach a getter to the object3D to the shape
   *
   * @param {PolygonJSON|CircleJSON} json - shape json
   */
  initShapeFromJSON(json) {
    switch (json.type) {
      case ColliderComponent.SHAPE_TYPE.CIRCLE:
        {
          const circle = new Circle(
            parseFloat(json.center.x),
            parseFloat(json.center.y),
            parseFloat(json.radius)
          );

          /**
           * update world transform of shape
           *
           * @param {{x:number,y:number}} worldPosition - world position
           * @param {*} worldRotation - world rotation useless here since this is not an ellipse but a circle
           * @param {{x:number,y:number}} worldScale - world scale
           */
          this.update = (worldPosition, worldRotation, worldScale) => {
            circle.x = json.center.x + worldPosition.x;
            circle.y = json.center.y + worldPosition.y;
            circle.scale = Math.max(worldScale.x, worldScale.y); // take the bigger scale
          };

          this.shape = circle;
        }
        break;
      case ColliderComponent.SHAPE_TYPE.POLYGON:
        {
          const points = [];
          json.points.forEach((p) => {
            points.push([parseFloat(p.x), parseFloat(p.y)]);
          });

          if (
            polygon2DArea(
              points.map((el) => {
                return { x: el[0], y: el[1] };
              })
            ) < 0
          )
            points.reverse(); // if area is negative it means polygon points are in the wrong order

          const polygon = new Polygon(0, 0, points);

          /**
           * update world transform of shape
           *
           * @param {{x:number,y:number}} worldPosition - world position
           * @param {{z:number}} worldRotation - world rotation
           * @param {{x:number,y:number}} worldScale - world scale
           */
          this.update = (worldPosition, worldRotation, worldScale) => {
            polygon.x = worldPosition.x;
            polygon.y = worldPosition.y;
            polygon.angle = worldRotation.z;
            polygon.scale_x = worldScale.x;
            polygon.scale_y = worldScale.y;
          };

          this.shape = polygon;
        }
        break;
      default:
    }

    // attach a getter to the shape so its object3D attach can be access in colllide result {@link Context}
    this.shape.getWrapper = () => {
      return this;
    };
  }
}

/**
 * `MODULE` Collider
 *
 * @exports Collider
 */

module.exports = {
  /** @see ColliderComponent */
  Component: ColliderComponent,
  /** @see ColliderModel */
  Model: ColliderModel,
  /** @see ColliderController */
  Controller: ColliderController,
};