Source: ./src/engine/core/game-object.js

import Color from "/src/engine/data-structure/color.js";
import {
  DefaultLayer,
  Layer,
  TerrainLayer,
} from "/src/engine/data-structure/layer.js";
import Matrix from "/src/engine/data-structure/matrix.js";
import Transform from "/src/engine/data-structure/transform.js";
import RigidBody from "/src/engine/data-structure/rigidbody.js";
import Vector from "/src/engine/data-structure/vector.js";
import { BoxCollider } from "/src/engine/data-structure/collider.js";

import DestroyManager from "/src/engine/core/destroy-manager.js";
import InputManager from "/src/engine/core/input-manager.js";
import SceneManager from "/src/engine/core/scene-manager.js";
import RenderManager from "/src/engine/core/render-manager.js";

import { typeCheck } from "/src/engine/utils.js";

/**
 * 게임에 등장하는 모든 객체의 기본형태다.
 * 게임에 등장하는 모든 객체는 GameObject를 상속받아 구현된다.
 */
class GameObject {
  /**
   * @constructor
   * @param {object} [options]
   * @param {string} [options.name]
   * @param {boolean} [options.isActive]
   * @param {boolean} [options.isVisible]
   * @param {Layer} [options.layer]
   * @param {Color} [options.color=Random Color]
   * @param {boolean} [options.isPhysicsEnable=false]
   * @param {object} [options.transform]
   * @param {Vector} [options.transform.position=new Vector(0, 0)]
   * @param {Vector} [options.transform.scale=new Vector(1, 1)]
   * @param {number} [options.transform.rotation=0]
   * @param {object} [options.boundary]
   * @param {number} [options.boundary.width]
   * @param {number} [options.boundary.height]
   * @param {number} [options.boundary.offset]
   * @param {object} [options.rigidbody]
   * @param {number} [options.rigidbody.mass=1]
   * @param {number} [options.rigidbody.bounceness=0.5]
   * @param {number} [options.rigidbody.staticFriction=0.2]
   * @param {number} [options.rigidbody.dynamicFriction=0.1]
   * @param {boolean} [options.rigidbody.isStatic=false]
   * @param {boolean} [options.rigidbody.isGravity=false]
   * @param {boolean} [options.rigidbody.isTrigger=false]
   */
  constructor(options = {}) {
    /**
     * canvas에 이 객체를 렌더링할 때 사용할 context다.
     *
     * @type {CanvasRenderingContext2d}
     */
    this.context2d = RenderManager.getRenderCanvas().getContext("2d");
    /**
     * 생성된 객체의 이름을 말한다.
     * 기본값으로는 생성자의 이름을 저장한다.
     *
     * @type {string}
     */
    this.name = typeCheck(options.name, "string", this.constructor.name);
    /**
     * 화면에 이 객체를 그릴 것인지를 의미한다.
     * 기본값은 true다.
     *
     * @type {boolean}
     */
    this.isVisible = typeCheck(options.isVisible, "boolean", true);
    /**
     * 이 객체가 활성상태인지 의미한다.
     * 만약 이 값이 false라면 update와 render가 실행되지 않는다.
     * 이 값이 false라면 물리엔진에서도 수집하지 않는다.
     * 기본값은 true다.
     *
     * @type {boolean}
     */
    this.isActive = typeCheck(options.isActive, "boolean", true);
    /**
     * 객체의 레이어다.
     *
     * @type {Layer}
     */
    this.layer = typeCheck(options.layer, Layer, new DefaultLayer());
    /**
     * 물리효과를 위한 강체다.
     *
     * @type {RigidBody}
     */
    this.rigidbody = new RigidBody(options.rigidbody);

    if (this.rigidbody.isStatic) {
      this.rigidbody.inverseMass = 0;
    }
    /**
     * 이 객체의 좌표, 크기, 각도 등을 의미한다.
     *
     * @type {Transform}
     */
    this.transform = new Transform(options.transform);

    /**
     * 이 객체에 물리효과를 적용할건지를 의미한다.
     * 기본적으론 적용하지 않는다.
     *
     * @type {boolean}
     */
    this.isPhysicsEnable = typeCheck(options.isPhysicsEnable, "boolean", false);

    /**
     * 이 객체의 Collision 타입을 나타낸다.
     * 기본값으로는 상자 형태(BoxCollider)를 사용한다.
     *
     * @type {BoxCollider}
     */
    this.collider = new BoxCollider(options.boundary);
    /**
     * 렌더링에 사용될 색상값이다.
     *
     * @type {Color}
     */
    this.color = typeCheck(
      options.color,
      Color,
      new Color(
        Math.random() * 255,
        Math.random() * 255,
        Math.random() * 255,
        1
      )
    );
    /**
     * 이 객체의 transform을 행렬로 나타낸 결과다.
     * canvas에서 좌표, 회전, 규모를 이용해 물체를 그리려면 행렬을 이용해야한다.
     * 그래서 이 객체의 transform을 canvas에서 활용할 수 있게
     * matrix로 변환하여 저장한다.
     *
     * @type {Matrix}
     */
    this.matrix = this.transform.toMatrix();
    /**
     * 이전 프레임의 matrix와 현재 프레임의 matrix를 선형보간하기 위해
     * 이전 프레임의 matrix를 저장해야한다.
     *
     * @type {Matrix}
     */
    this.previousMatrix = this.transform.toMatrix();
    /**
     * 이 객체의 자식들을 저장할 테이블이다.
     *
     * @type {array}
     */
    this.childList = new Array();
    /**
     * 이 객체의 부모 객체다.
     * 부모의 matrix를 이용해 자신의 matrix를 만들고,
     * 이 객체를 삭제할 때 부모에 의해 삭제되기 때문에
     * 이 객체의 부모를 기억해야 한다.
     *
     * @type {GameObject}
     */
    this.parent = undefined;
  }

  /**
   * GameObject 내에서는 단순히 이 오브젝트에 등록된
   * 하위 GameObject들의 update를 실행시킨다.
   *
   * @param {number} deltaTime - 이전 프레임과 현재 프레임의 시간차
   */
  update(deltaTime) {
    if (this.isActive) {
      this.childList.forEach((child) => {
        child.update(deltaTime);
      });
    }
  }

  /**
   * 가속도를 적분하여 속도에 누적한다.
   *
   * @param {number} deltaTime - 이전 프레임과 현재 프레임의 시간차
   */
  integrateForce(deltaTime) {
    // inverseMass가 0이라는 말은 static 객체임을 말한다.
    if (this.getInverseMass() === 0) {
      return;
    }

    // rigidbody의 isGravity가 참일 때에만 중력가속도를 적용한다.
    const acceleration = new Vector(0, 0);
    if (this.rigidbody.isGravity) {
      acceleration.y += 9.8;
    }
    this.setAcceleration(acceleration);

    this.addVelocity(this.getAcceleration().multiply(deltaTime));
  }

  /**
   * 속도를 적분하여 좌표값에 누적한다.
   *
   * @param {number} deltaTime - 이전 프레임과 현재 프레임의 시간차
   */
  integrateVelocity(deltaTime) {
    // inverseMass가 0이라는 말은 static 객체임을 말한다.
    if (this.getInverseMass() === 0) {
      return;
    }
    this.addPosition(this.getVelocity().multiply(deltaTime));
    // 변한 좌표값이 matrix에는 저장되지 않으므로
    // 어쩔 수 없이 matrix로 바꾸는 연산을 수행한다.
    if (this.hasParentGameObject()) {
      this.multiplyParentMatrix();
    } else {
      this.matrix = this.transform.toMatrix();
    }
  }

  /**
   * transform을 matrix로 변환한다.
   * 이 때 부모 객체가 있다면 행렬곱을 수행해 두 matrix를 결합한다.
   * 이 객체의 transform만 변환을 수행하지 않고 이 객체의 자식들에게도
   * calculateMatrix를 호출한다.
   */
  calculateMatrix() {
    if (this.isActive) {
      if (this.hasParentGameObject()) {
        this.multiplyParentMatrix();
      } else {
        this.matrix = this.transform.toMatrix();
      }

      this.childList.forEach((child) => {
        child.calculateMatrix();
      });
    }
  }

  /**
   * 먼저 선형보간한 matrix를 사용해 context에 등록한다.
   * 그 다음 draw()를 통해 물체를 렌더링한다.
   * 이 객체를 상속받은 Rect나 Circle처럼 자식객체에 따라
   * 각각 다른 렌더링이 수행된다.
   * 그 후 이 객체의 모든 자식들을 렌더링한다.
   */
  render() {
    if (this.isActive && this.isVisible) {
      this.beforeDraw();

      this.setTransform();

      this.draw();

      this.childList.forEach((child) => {
        child.render();
      });

      this.afterDraw();
    }
  }

  /**
   * 이 객체의 부모가 존재한다면 true를 반환한다.
   *
   * @returns {boolean}
   */
  hasParentGameObject() {
    return this.parent !== undefined;
  }

  /**
   * 자신의 transform을 matrix로 변환한 것과 부모의 matrix를 행렬곱한다.
   */
  multiplyParentMatrix() {
    const parentMatrix = this.parent.getMatrix();
    this.matrix = parentMatrix.multiply(this.transform.toMatrix());
  }

  /**
   * 계산된 matrix를 context2d에 적용한다.
   */
  setTransform() {
    this.context2d.setTransform(
      this.matrix.a,
      this.matrix.b,
      this.matrix.c,
      this.matrix.d,
      this.matrix.x,
      this.matrix.y
    );
  }

  /**
   * 렌더링 전에 색상값을 갱신한다거나 다른 작업이 미리 처리되어야 하는 경우
   * 이 함수를 오버라이드해서 사용하면 된다.
   *
   * 이 객체에서는 기본적으로 색상값을 적용시키기 위해 globalAlpha값을 조절한다.
   * context2d.save()를 통해 현재 설정값을 저장해두었으므로,
   * 마음대로 변경 후 context2d.restore()로 되돌리게 된다.
   * 따라서 현재 설정값이 자식들의 렌더링에 사용된다.
   */
  beforeDraw() {
    this.context2d.save();
    this.context2d.globalAlpha *= this.color.a;
  }

  /**
   * 이 함수는 GameObject를 상속받은 객체마다 다르게 동작한다.
   * GameObject 자체는 렌더링할 대상이 없지만 Sprite나, Rect, Text 등
   * GmaeObject를 상속받은 객체들은 명확히 렌더링할 대상이 존재한다.
   * 그 때 이 함수안에서 어떻게 렌더링할건지 정의를 해 놓으면 된다.
   * super.render()를 먼저 호출하고 대상을 렌더링할 경우
   * 이미 렌더링된 자식 오브젝트를 덮어씌워 렌더링할 수 있으므로
   * 렌더링만큼은 draw 함수 내에서만 정의를 하는게 좋다.
   */
  draw() {}

  /**
   * 일반적으로 beforeDraw()에서 전처리했던 것을 원래대로 돌리는 작업을 한다.
   * beforeDraw()를 오버라이드했다면 이 함수도 오버라이드하여
   * 수정된 context2d를 원래대로 돌려놓아야 한다.
   */
  afterDraw() {
    this.context2d.restore();
  }

  /**
   * 이 객체의 이름을 반환한다.
   *
   * @returns {string}
   */
  getName() {
    return this.name;
  }

  /**
   * 이 객체의 이름을 설정한다.
   * 기본값으로는 생성자의 이름을 저장하게 된다.
   *
   * @param {string} name - 객체의 이름
   */
  setName(name) {
    this.name = typeCheck(name, "string", this.constructor.name);
  }

  /**
   * isActive를 true로 설정한다.
   */
  activate() {
    this.isActive = true;
  }

  /**
   * isActive를 false로 설정한다.
   */
  deactivate() {
    this.isActive = false;
  }

  /**
   * isVisible을 true로 설정한다.
   */
  show() {
    this.isVisible = true;
  }

  /**
   * isVisible을 false로 설정한다.
   */
  hide() {
    this.isVisible = false;
  }

  /**
   * 레이어를 반환한다.
   *
   * @return {Layer}
   */
  getLayer() {
    return this.layer;
  }

  /**
   * 인자로 받은 객체와 이 객체 사이에 부모-자식 관계를 만든다.
   * 만약 이 객체의 부모가 이미 있다면, 그 부모의 자식 테이블에서 이 객체를
   * 제거하고, 새로운 부모의 자식 테이블에 이 객체를 추가한다.
   *
   * @param {GameObject} parent - 이 객체의 부모가 될 객체
   */
  setParent(parent) {
    // 이 객체의 부모 객체가 있다면
    // 부모 객체로부터 자식 객체를 제거한다.
    if (this.parent !== undefined) {
      const index = this.parent.childList.indexOf(this);
      this.parent.childList.splice(index, 1);
    }

    this.parent = parent;
    this.parent.addChild(this);
  }

  /**
   * 인자로 전달받은 객체가 자식 테이블에 존재한다면,
   * 그 객체를 자식 테이블에서 제거하고,
   * 씬 객체의 자식 테이블에 추가한다.
   *
   * @param {GameObject} child - 자식 리스트에서 지워질 자식 객체
   */
  removeChild(child) {
    const index = this.childList.indexOf(child);
    const isChildExist = index !== -1;
    if (isChildExist) {
      this.childList.splice(index, 1);

      // 자식 객체의 부모를 씬 객체로 변경한다.
      SceneManager.getCurrentScene().addChild(child);
    }
  }

  /**
   * 이 객체의 부모와 이 객체 사이의 관계를 끊는다.
   * 이 객체는 부모로부터 떨어져 나오게 되는데,
   * 씬 객체를 새로운 부모로 설정한다.
   */
  removeParent() {
    // 만약 이 객체의 부모가 있어야지만 부모 객체로부터 떨어져 나올 수 있다.
    if (this.parent !== undefined) {
      // 부모 객체에게 자식을 삭제하라는 명령으로
      // 자식 객체의 부모를 삭제하는 것과
      // 부모 객체의 자식 목록에서 자식 객체를 삭제하라는 것을 해결할 수 있다.
      this.parent.removeChild(this);
    }
  }

  /**
   * 자식 목록에 인자로 전달된 객체를 추가한다.
   *
   * @param {GameObject} child - 이 객체의 자식으로 추가될 객체
   */
  addChild(child) {
    // 만약 이미 있는 자식 객체라면 추가하지 않는다.
    const index = this.childList.indexOf(child);
    if (index !== -1) {
      return;
    }
    this.childList.push(child);
    child.setParent(this);
  }

  /**
   * 이 객체가 물리효과에 의해 다른 객체와 충돌했을 때
   * 이 함수가 호출된다.
   *
   * @param {GameObject} other - 이 객체와 충돌한 다른 객체
   */
  onCollision(other) {}

  /**
   * 이 객체의 좌표값에 특정값을 더한다.
   *
   * @param {Vector} position - 이 객체의 좌표에 더해질 좌표값
   */
  addPosition(position) {
    this.transform.position = this.transform.position.add(position);
  }

  /**
   * 이 객체의 좌표값을 특정값으로 설정한다.
   *
   * @param {Vector} position - 이 객체의 좌표로 설정될 좌표값
   */
  setPosition(position) {
    this.transform.position = position;
  }

  /**
   * 이 객체의 좌표값을 반환한다.
   *
   * @returns {Vector}
   */
  getPosition() {
    return this.transform.position;
  }

  /**
   * 이 객체의 화면상 좌표값을 반환한다.
   * Canvas에 이 객체를 렌더링할 때 사용하는 matrix에서
   * x, y값을 벡터로 만들어 반환한다.
   *
   * @returns {Vector}
   */
  getWorldPosition() {
    return new Vector(this.matrix.x, this.matrix.y);
  }

  /**
   * 이 객체의 크기를 특정값만큼 변경한다.
   *
   * @param {Vector} 이 객체의 규모에 더해질 규모값
   */
  addScale(scale) {
    this.transform.scale = this.transform.scale.add(scale);
  }

  /**
   * 이 객체의 크기를 특정값으로 설정한다.
   *
   * @param {Vector} 이 객체의 규모로 설정될 규모값
   */
  setScale(scale) {
    this.transform.scale = scale;
  }

  /**
   * 이 객체의 규모(스케일값)값을 반환한다.
   * 크기(size)를 반환하는게 아니다!
   *
   * @returns {Vector}
   */
  getScale() {
    return this.transform.scale;
  }

  /**
   * 이 객체의 화면상 규모값을 반환한다.
   *
   * @returns {Vector}
   */
  getWorldScale() {
    const rad = (this.getWorldRotation() * Math.PI) / 180;

    const x =
      rad != 0 ? this.matrix.b / Math.sin(rad) : this.matrix.a / Math.cos(rad);
    const y =
      rad != 0 ? -this.matrix.c / Math.sin(rad) : this.matrix.d / Math.cos(rad);
    return new Vector(x, y);
  }

  /**
   * 이 객체의 각도(degree)를 특정값만큼 변경한다.
   *
   * @param {number} degree - 이 객체의 각도에 더해질 각도값(degree)
   */
  addRotation(degree) {
    this.transform.rotation += degree;
  }

  /**
   * 이 객체의 각도(degree)를 특정값으로 설정한다.
   *
   * @param {number} degree - 이 객체의 각도로 설정될 각도값(degree)
   */
  setRotation(degree) {
    this.transform.rotation = degree;
  }

  /**
   * 이 객체의 각도(degree)를 반환한다.
   *
   * @returns {number}
   */
  getRotation() {
    return this.transform.rotation;
  }

  /**
   * 이 객체의 화면상 각도(degree)를 반환한다.
   *
   * @return {number}
   */
  getWorldRotation() {
    const a = this.matrix.a;
    const b = this.matrix.b;
    return (Math.atan2(b, a) * 180) / Math.PI;
  }

  /**
   * 이 객체의 속도를 특정값만큼 증가시킨다.
   *
   * @param {Vector} velocity - 이 객체의 속도에 더해질 속도값
   */
  addVelocity(velocity) {
    this.transform.velocity = this.transform.velocity.add(velocity);
  }

  /**
   * 이 객체의 속도를 특정값으로 설정한다.
   *
   * @param {Vector} velocity
   */
  setVelocity(velocity) {
    this.transform.velocity = velocity;
  }

  /**
   * 이 객체의 속도를 반환한다.
   *
   * @returns {Vector}
   */
  getVelocity() {
    return this.transform.velocity;
  }

  /**
   * 이 객체의 가속도를 특정값만큼 증가시킨다.
   *
   * @param {Vector} 이 객체의 가속도에 더해질 가속도값
   */
  addAcceleration(acceleration) {
    this.transform.acceleration = this.transform.acceleration.add(acceleration);
  }

  /**
   * 이 객체의 가속도를 특정값으로 설정한다.
   *
   * @param {Vector} accelration
   */
  setAcceleration(accelration) {
    this.transform.acceleration = accelration;
  }

  /**
   * 이 객체의 가속도를 반환한다.
   *
   * @returns {Vector}
   */
  getAcceleration() {
    return this.transform.acceleration;
  }

  /**
   * 이 객체의 물리적 크기를 반환한다.
   *
   * @returns {Vector}
   */
  getSize() {
    return this.transform.size;
  }

  /**
   * 이 객체의 화면상 물리적 크기를 반환한다.
   * 이 객체의 크기에 화면상 규모를 곱한 값을 반환하게 된다.
   *
   * @returns {Vector}
   */
  getWorldSize() {
    return this.getSize().elementMultiply(this.getWorldScale());
  }

  /**
   * 이 객체의 화면상 외형의 크기를 반환한다.
   * 기본적으로 BoxCollider를 사용하기 때문에 상자 형태의 크기가 반환된다.
   *
   * @returns {Vector}
   */
  getWorldBoundary() {
    return this.getBoundary().elementMultiply(this.getWorldScale());
  }

  /**
   * 이 객체의 외형의 크기를 반환한다.
   * 기본적으로 BoxCollider를 사용하기 때문에 상자 형태의 크기가 반환된다.
   *
   * @returns {Vector}
   */
  getBoundary() {
    return this.collider.getBoundary();
  }

  /**
   * 이 객체의 화면상 좌표값에 외형의 오프셋값을 더한 좌표를 반환한다.
   * 이 때 오프셋에도 WorldScale을 적용해 더한다.
   *
   * @returns {Vector}
   */
  getColliderWorldPosition() {
    return this.getWorldPosition().add(
      this.getColliderOffset().elementMultiply(this.getWorldScale())
    );
  }

  /**
   * 이 객체의 외형의 오프셋값을 반환한다.
   *
   * @returns {Vector}
   */
  getColliderOffset() {
    return this.collider.getOffset();
  }

  /**
   * 이 객체의 matrix를 반환한다.
   *
   * @returns {Matrix}
   */
  getMatrix() {
    return this.matrix;
  }

  /**
   * 이 객체의 탄성값을 반환한다.
   *
   * @returns {number}
   */
  getBounceness() {
    return this.rigidbody.bounceness;
  }

  /**
   * 이 객체의 정지 마찰 계수를 반환한다.
   *
   * @returns {number}
   */
  getStaticFriction() {
    return this.rigidbody.staticFriction;
  }

  /**
   * 이 객체의 운동 마찰 계수를 반환한다.
   *
   * @returns {number}
   */
  getDynamicFriction() {
    return this.rigidbody.dynamicFriction;
  }

  /**
   * 이 객체의 질량값을 반환한다.
   *
   * @returns {number}
   */
  getMass() {
    return this.rigidbody.mass;
  }

  /**
   * 이 객체의 질량값의 역수를 반환한다.
   *
   * @returns {number}
   */
  getInverseMass() {
    return this.rigidbody.inverseMass;
  }

  /**
   * 이 객체를 씬으로부터 제거한다.
   * 이 객체의 자식 테이블에 있는 모든 객체들도 연달아 제거된다.
   *
   * 이 객체를 제거하기위해 DestroyManager에 등록한다.
   * 이 객체가 등록되었다면 업데이트가 끝난 직후
   * DestroyManager가 등록된 객체들을 제거한다.
   */
  destroy() {
    DestroyManager.push(this);

    this.childList.forEach((child) => {
      child.destroy();
    });
  }

  /**
   * 이 객체 위에 마우스가 올라가 있는지를 반환한다.
   * 기본적으로 worldSize값과 worldPosition을 이용해 계산한다.
   *
   * @returns {boolean}
   */
  isMouseOver() {
    const size = this.getWorldSize();
    const position = this.getWorldPosition();
    const leftTop = position.minus(size.multiply(0.5));
    const rightBottom = position.add(size.multiply(0.5));
    const mousePos = InputManager.getMousePos();
    return (
      leftTop.x < mousePos.x &&
      mousePos.x < rightBottom.x &&
      leftTop.y < mousePos.y &&
      mousePos.y < rightBottom.y
    );
  }

  /**
   * 마우스 왼쪽 버튼으로 이 객체를 클릭했는지를 반환한다.
   *
   * @returns {boolean}
   */
  isLeftMouseClickThis() {
    return InputManager.isKeyDown("leftMouse") && this.isMouseOver();
  }

  /**
   * 마우스 오른쪽 버튼으로 이 객체를 클릭했는지를 반환한다.
   *
   * @returns {boolean}
   */
  isRightMouseClickThis() {
    return InputManager.isKeyDown("rightMouse") && this.isMouseOver();
  }
}

export default GameObject;