Source: ./src/engine/fx/particle-effect.js

import GameObject from "/src/engine/core/game-object.js";
import Sprite from "/src/engine/core/sprite.js";
import Vector from "/src/engine/data-structure/vector.js";

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

/**
 * 파티클 이펙트에 사용할 파티클이다.
 * 파티클 효과의 종류는 점점 투명해지면서 사라지는 것,
 * 크기가 작아지면서 사라지는 것 등이 있다.
 *
 * @extends {Sprite}
 */
class Particle extends Sprite {
  /**
   * @constructor
   * @param {object} options
   * @param {number} [options.lifeTime]
   * @param {number} [options.initialLifeTime]
   * @param {boolean} [options.isScaleFade]
   * @param {boolean} [options.isAlphaFade]
   * @param {number} [options.direction]
   * @param {number} [options.diffuseness]
   * @param {number} [options.speed]
   * @param {Color} [options.color]
   * @param {boolean} [options.isColorOverlayEnable]
   * @param {Color} [options.overlayColor]
   * @param {boolean} [options.isPhysicsEnable]
   * @param {Transform} [options.transform]
   * @param {RigidBody} [options.rigidbody]
   */
  constructor(options) {
    super(options);
    /** @type {number} */
    this.lifeTime = options.lifeTime;
    /** @type {number} */
    this.initialLifeTime = options.lifeTime;
    /** @type {boolean} */
    this.isScaleFade = options.isScaleFade;
    /** @type {boolean} */
    this.isAlphaFade = options.isAlphaFade;

    /** @type {number} */
    this.direction =
      options.direction -
      options.diffuseness +
      Math.random() * options.diffuseness * 2;

    /** @type {number} */
    this.speed = options.speed;

    const rad = -(this.direction * Math.PI) / 180;
    /** @type {Vector} */
    this.forward = new Vector(
      Math.cos(rad) * this.speed,
      Math.sin(rad) * this.speed
    );
    if (this.isPhysicsEnable) {
      this.addVelocity(forward);
    }
  }

  /**
   * 파티클 효과를 업데이트한다.
   * 지속 시간이 지났다면 제거한다.
   *
   * @param {number} deltaTime - 이전 프레임와 현재 프레임의 시간차
   */
  update(deltaTime) {
    this.fadeAway();
    if (this.isPhysicsEnable === false) {
      this.spreadOut(deltaTime);
    }

    this.lifeTime -= deltaTime;

    if (this.lifeTime < 0) {
      this.destroy();
    }
  }

  /**
   * 각 파티클 효과가 켜져있다면 그 파티클 효과를 적용한다.
   */
  fadeAway() {
    if (this.isScaleFade) {
      this.setScale(
        new Vector(
          this.lifeTime / this.initialLifeTime,
          this.lifeTime / this.initialLifeTime
        )
      );
    }
    if (this.isAlphaFade) {
      this.color.a = this.lifeTime / this.initialLifeTime;
    }
  }

  /**
   * 만약 물리효과가 적용되지 않는다면 직접 position을 변경해야한다.
   *
   * @param {number} deltaTime - 이전 프레임와 현재 프레임의 시간차
   */
  spreadOut(deltaTime) {
    this.addPosition(this.forward.multiply(deltaTime));
  }
}

/**
 * 폭죽이 터지는 효과나 탄환 궤적 효과 등을 만드는 객체다.
 *
 * @extends {GameObject}
 */
class ParticleEffect extends GameObject {
  /**
   * @constructor
   * @param {object} options
   * @param {boolean} [options.isEnable]
   * @param {number} [options.duration]
   * @param {boolean} [options.isScaleFade]
   * @param {boolean} [options.isAlphaFade]
   * @param {number} [options.countPerSecond]
   * @param {number} [options.direction]
   * @param {number} [options.diffuseness]
   * @param {number} [options.speed]
   * @param {number} [options.lifeTime]
   * @param {string} [options.imagePath]
   * @param {boolean} [options.isParticlePhysicsEnable]
   * @param {Color} [options.color]
   * @param {boolean} [options.isColorOverlayEnable]
   * @param {Color} [options.overlayColor]
   * @param {Transform} [options.transform]
   * @param {RigidBody} [options.rigidbody]
   */
  constructor(options) {
    super(options);

    /**
     * 파티클 이미지가 없을 때 기본으로 사용할 이미지의 경로
     *
     * @type {string}
     * */
    this.defaultParticleImage = "/src/engine/assets/defaultParticleImage.png";

    /**
     * 파티클이 생성되려면 isEnable이 true여야 한다.
     * 기본값은 false다.
     *
     * @type {boolean}
     */
    this.isEnable = typeCheck(options.isEnable, "boolean", false);
    /**
     * 파티클 이펙트의 지속시간을 말한다.
     * 이 값이 0이라면 파티클 이펙트가 무한히 재생된다.
     * 이 값이 3이라면 3초 동안만 파티클 이펙트가 재생된다.
     * 기본값은 0이다.
     */
    this.duration = typeCheckAndClamp(
      options.duration,
      "number",
      0,
      0,
      Number.MAX_VALUE
    );
    /**
     * 이 값이 true라면 파티클이 점점 크기가 작아지는 효과를 낸다.
     * 기본값은 true다.
     *
     * @type {boolean}
     */
    this.isScaleFade = typeCheck(options.isScaleFade, "boolean", true);
    /**
     * 이 값이 true라면 파티클이 점점 투명해진다.
     * 기본값은 true다.
     *
     * @type {boolean}
     */
    this.isAlphaFade = typeCheck(options.isAlphaFade, "boolean", true);
    /**
     * 1초동안 생성할 파티클의 개수를 의미한다.
     * 파티클 생성 주기는 이 값에 의해 결정된다.
     * 값의 범위는 1 ~ 100이다.
     * 기본값은 10이다.
     *
     * @type {number}
     */
    this.countPerSecond = typeCheckAndClamp(
      options.countPerSecond,
      "number",
      30,
      1,
      100
    );
    /**
     * 파티클이 생성되어 퍼져나가는 주 방향을 의미한다.
     * 값의 범위는 0 ~ 360이다.
     * 기본값은 0(degree)이다.
     *
     * @type {number}
     */
    this.direction = typeCheckAndClamp(options.direction, "number", 0, 0, 360);
    /**
     * 파티클이 생성되고 나서 퍼져나갈 때 퍼짐 정도를 의미한다.
     * 파티클이 퍼져나가는 방향은 최종적으로 아래에 명시된 범위이다.
     * direction - diffuseness ~ direction + diffuseness
     *
     * 만약 direction이 90도이고 diffuseness가 30도라면
     * 파티클은 60도 ~ 120도 사이 방향중에서 랜덤하게 결정된 방향으로 퍼진다.
     * 값의 범위는 0 ~ 180이다.
     * 기본값은 30(degree)이다
     *
     * @type {number}
     */
    this.diffuseness = typeCheckAndClamp(
      options.diffuseness,
      "number",
      30,
      0,
      180
    );
    /**
     * 파티클이 날아가는 속도를 의미한다.
     * 값이 낮을수록 파티클이 멀리 퍼지지 않게 된다.
     * 값의 범위는 10 ~ 1000이다.
     * 기본값은 10이다.
     *
     * @type {number}
     */
    this.speed = typeCheckAndClamp(options.speed, "number", 100, 10, 1000);
    /**
     * lifeTime은 생성된 파티클이 몇 초동안 화면에 보일지를 의미한다.
     * 값의 범위는 0.1 ~ 10.0이다.
     * 기본값은 3초다.
     *
     * @type {number}
     */
    this.lifeTime = typeCheckAndClamp(options.lifeTime, "number", 3, 0.1, 10);
    /**
     * 파티클의 이미지 경로를 의미한다.
     * 기본값으로는 엔진에 딸린 이미지를 불러오게 된다.
     *
     * @type {string}
     */
    this.imagePath = typeCheck(
      options.imagePath,
      "string",
      this.defaultParticleImage
    );
    /**
     * 만약 isPhysicsEnabled가 true라면, 파티클에도 물리효과가 적용된다.
     * 기본값은 false다.
     *
     * @type {boolean}
     */
    this.isParticlePhysicsEnable = typeCheck(
      options.isParticlePhysicsEnable,
      "boolean",
      false
    );
    /**
     * 파티클 효과가 켜진 후 지난 시간을 나타낸다.
     *
     * @type {number}
     */
    this.elapsedTime = 0;
    /**
     * 파티클 효과가 켜진 후 지난 시간을 나타낸다.
     * elapsedTime과 다른 점은 이 값은 파티클 생성에 사용되기 때문에
     * 이 값이 unitTime보다 커질 경우 파티클을 생성하게 된다.
     *
     * @type {number}
     */
    this.accumulatedTime = 0;

    if (this.isEnable) {
      this.run();
    }
  }

  /**
   * 만약 isEnable이 true라면 일정 시간마다 파티클을 생성한다.
   *
   * @param {number} deltaTime - 이전 프레임와 현재 프레임의 시간차
   */
  update(deltaTime) {
    super.update(deltaTime);
    if (this.isEnable) {
      this.elapsedTime += deltaTime;
      this.accumulatedTime += deltaTime;

      // 파티클 효과가 켜지면 파티클들을 생성한다.
      // 누적된 시간이 unitTime보다 커질 때에만 파티클을 생성한다.
      if (this.accumulatedTime > this.unitTime) {
        this.accumulatedTime %= this.unitTime;
        const options = {
          direction: this.direction,
          diffuseness: this.diffuseness,
          speed: this.speed,
          lifeTime: this.lifeTime,
          isPhysicsEnable: this.isParticlePhysicsEnable,
          isAlphaFade: this.isAlphaFade,
          isScaleFade: this.isScaleFade,
          imagePath: this.imagePath,
        };
        const newParticle = new Particle(options);
        this.addChild(newParticle);
      }

      if (this.duration > 0 && this.elapsedTime > this.duration) {
        this.stop();
      }
    }
  }

  /**
   * 파티클 효과를 켠다.
   */
  run() {
    this.isEnable = true;
    // countPerSecond를 이용해서 파티클 1개를 생성하려면
    // 정확히 몇 초가 지나야지만 생성할 수 있는지 미리 계산한다.
    this.unitTime = 1 / this.countPerSecond;
    this.elapsedTime = 0;
    this.accumulatedTime = 0;
  }

  /**
   * 파티클 효과를 끈다.
   */
  stop() {
    this.isEnable = false;
  }
}

export default ParticleEffect;