Source: ./src/engine/core/physics-manager.js

import {
  BoxCollider,
  CircleCollider,
} from "/src/engine/data-structure/collider.js";

import BoxCollisionResolver from "/src/engine/core/box-collision-resolver.js";
import CircleCollisionResolver from "/src/engine/core/circle-collision-resolver.js";

/**
 * 씬 객체에 물리효과를 적용하는 책임은 PhysicsManager이 맡는다.
 * 물리효과를 적용할 객체들에게만 물리효과를 적용한다.
 *
 * 참고한 사이트
 * https://github.com/tutsplus/ImpulseEngine/
 * https://github.com/Kareus/SP2C/
 * https://kareus.tistory.com/15
 */
class PhysicsManager {
  /**
   * 물리효과가 적용될 객체들
   *
   * @property {array}
   * @static
   */
  static physicsEnableGameObjectList = new Array();

  constructor() {}

  /**
   * 씬 객체 내에 존재하는 오브젝트들중
   * 물리효과가 켜진 오브젝트들에게 물리효과를 계산해 적용한다.
   *
   * @param {GameObject} scene - 현재 씬
   * @param {number} deltaTime - 이전 프레임과 현재 프레임의 시간차
   */
  static update(scene, deltaTime) {
    PhysicsManager.collectPhysicsEnabledGameObjectToList(scene);

    const objectList = PhysicsManager.physicsEnableGameObjectList;
    const length = objectList.length;

    const manifoldList = new Array();
    for (let i = 0; i < length; i++) {
      const obj = objectList[i];

      // 충돌체크를 진행할 대상의 Collider 타입에 따라
      // Resolver를 선택해 진행한다.
      let collisionResolver = null;
      if (obj.collider instanceof BoxCollider) {
        collisionResolver = new BoxCollisionResolver(obj);
      }
      if (obj.collider instanceof CircleCollider) {
        collisionResolver = new CircleCollisionResolver(obj);
      }

      // 현재 객체가 다른 객체와 충돌했는지 검사한다.
      // 만약 충돌했을 경우 이벤트함수를 실행하고 물리효과를 적용한다.
      for (let j = i + 1; j < length; j++) {
        const other = objectList[j];
        // 두 객체가 모두 static rigidbody일 경우에는
        // 충돌체크를 하지 않는다.
        if (obj.rigidbody.isStatic && other.rigidbody.isStatic) {
          continue;
        }

        // 두 객체의 레이어가 충돌체크를 하지 않는 레이어관계라면
        // 충돌체크를 하지 않는다.
        if (
          obj.getLayer().canPhysicsInteractLayerWith(other.getLayer()) === false
        ) {
          continue;
        }
        if (collisionResolver.isCollideWith(other)) {
          const manifold = collisionResolver.resolveCollision(other);
          if (manifold !== undefined) {
            manifoldList.push(manifold);
          }
        }
      }
    }

    /**
     * 물체의 가속도를 적분하여 속도에 누적한다.
     */
    objectList.forEach((obj) => {
      obj.integrateForce(deltaTime);
    });

    /**
     * 물체의 충돌을 계산하여 속도를 변화시킨다.
     */
    manifoldList.forEach((manifold) => {
      if (
        manifold.objA.rigidbody.isTrigger === false &&
        manifold.objB.rigidbody.isTrigger === false
      ) {
        PhysicsManager.applyImpulse(
          manifold.objA,
          manifold.objB,
          manifold.normal
        );
      }
      manifold.objA.onCollision(manifold.objB);
      manifold.objB.onCollision(manifold.objA);
    });

    /**
     * 속도를 적분하여 좌표값에 누적한다.
     */
    objectList.forEach((obj) => {
      obj.integrateVelocity(deltaTime);
    });

    /**
     * 서로 겹쳐지는 상황을 피하기 위해
     * 겹친 도형끼리 멀어지는 연산을 한다.
     */
    manifoldList.forEach((manifold) => {
      if (
        manifold.objA.rigidbody.isTrigger === false &&
        manifold.objB.rigidbody.isTrigger === false
      ) {
        PhysicsManager.positionalCorrection(manifold);
      }
    });

    // 더이상 참조하지 않기 위해 리스트를 초기화한다.
    PhysicsManager.physicsEnableGameObjectList = new Array();
  }

  /**
   * 씬 객체 내에 존재하는 모든 오브젝트들중
   * 물리효과를 받는 오브젝트들만 모아 리스트에 담는다.
   * 모든 객체를 조사해야하기 때문에 재귀호출하여 탐색한다.
   *
   * @param {GameObject} scene - 현재 씬
   */
  static collectPhysicsEnabledGameObjectToList(scene) {
    scene.childList.forEach((child) => {
      if (child.isActive) {
        if (child.isPhysicsEnable) {
          PhysicsManager.physicsEnableGameObjectList.push(child);
        }

        if (child.childList.length > 0) {
          PhysicsManager.collectPhysicsEnabledGameObjectToList(child);
        }
      }
    });
  }

  /**
   * 두 객체에게 충격량을 적용한다.
   *
   * @param {GameObject} objA - 서로 충돌한 객체1
   * @param {GameObject} objB - 서로 충돌한 객체2
   * @param {Vector} normal - 반작용 방향
   */
  static applyImpulse(objA, objB, normal) {
    const diff = objB.getVelocity().minus(objA.getVelocity());
    const dot = diff.dot(normal);

    // 두 객체의 속도(velocity:벡터)의 내적값이 양수라면
    // 두 객체가 서로 다른 방향으로 이동하고 있는 것이 아니라는 말이므로
    // 충돌체크를 하지 않는다.
    if (dot > 0) {
      return;
    }

    // 유니티에서는 탄성값을 적용할 때 avg, min, max 중 하나를 적용한다.
    // 여기서는 일단 min으로 적용한다.
    const e = Math.min(objA.getBounceness(), objB.getBounceness());

    // 충격량을 구하는 방정식을 통해 충격량을 계산한다.
    // 저도 잘 몰라요.
    let j = -(1 + e) * dot;
    j /= objA.getInverseMass() + objB.getInverseMass();
    const impulse = normal.multiply(j);

    objA.addVelocity(impulse.multiply(-objA.getInverseMass()));
    objB.addVelocity(impulse.multiply(objB.getInverseMass()));

    PhysicsManager.applyFriction(objA, objB, normal, j);
  }

  /**
   * 정지 마찰 계수와 운동 마찰 계수를 통해 마찰력을 적용한다.
   *
   * @param {GameObject} objA - 서로 충돌한 객체1
   * @param {GameObject} objB - 서로 충돌한 객체2
   * @param {Vector} normal - 반작용 방향
   * @param {number} j - 충격량
   */
  static applyFriction(objA, objB, normal, j) {
    // 충격이 전달된 후의 속도로 계산을 진행한다.
    // 두 물체의 속도 벡터의 차로 마찰이 작용할 방향을 찾는다.
    const relativeVelocity = objB.getVelocity().minus(objA.getVelocity());

    // relativeVelocity를 n에 정사영하여 normal방향 성분을 얻고,
    // 그 성분값을 다시 relativeVelocity에 빼서 normal에
    // 수직인 벡터를 구한다.
    let tangent = relativeVelocity.minus(
      normal.multiply(relativeVelocity.dot(normal))
    );
    tangent = tangent.normalize();

    // 마찰력의 크기를 구한다.
    let jt = -relativeVelocity.dot(tangent);
    jt /= objA.getInverseMass() + objB.getInverseMass();

    // 두 물체 사이의 정지 마찰 계수를 구한다.
    const staticFriction = Math.sqrt(
      objA.getStaticFriction() * objA.getStaticFriction() +
        objB.getStaticFriction() * objB.getStaticFriction()
    );

    // 정지 마찰 계수보다 큰 힘이 주어질 경우
    // 운동 마찰 계수를 이용해 마찰력을 결정한다.
    let frictionImpulse = null;
    if (Math.abs(jt) < j * staticFriction) {
      frictionImpulse = tangent.multiply(jt);
    } else {
      // 두 물체 사이의 운동 마찰 계수를 구한다.
      const dynamicFriction = Math.sqrt(
        objA.getDynamicFriction() * objA.getDynamicFriction() +
          objB.getDynamicFriction() * objB.getDynamicFriction()
      );
      frictionImpulse = tangent.multiply(-j * dynamicFriction);
    }

    objA.addVelocity(frictionImpulse.multiply(-objA.getInverseMass()));
    objB.addVelocity(frictionImpulse.multiply(objB.getInverseMass()));
  }

  /**
   * 충돌처리가 되었지만 서서히 빠져버리는 버그를 해결하기 위해
   * 충돌된 위치에서 정해진 값만큼 강제로 떨어지게 한다.
   *
   * @param {Manifold} manifold - 충돌체크의 결과
   */
  static positionalCorrection(manifold) {
    const percentage = 0.4; // ??? 0.2 ~ 0.8
    const slop = 0.05; // ??? 0.01 ~ 0.1
    const correction = manifold.normal.multiply(
      (Math.max(manifold.penetrationDepth - slop, 0) /
        (manifold.objA.getInverseMass() + manifold.objB.getInverseMass())) *
        percentage
    );

    let objACorrection = correction.multiply(-manifold.objA.getInverseMass());
    let objBCorrection = correction.multiply(manifold.objB.getInverseMass());
    manifold.objA.addPosition(objACorrection);
    manifold.objB.addPosition(objBCorrection);
  }
}

export default PhysicsManager;