emitters/Emitter.js

import { Force } from '../forces/Force.js';
import { Modifier } from '../modifiers/Modifier.js';
import { Particle } from '../core/Particle.js';
import { ModifierCategory, EmissionSource } from '../shared/Constants.js';

/**
 * @import { Color, EmissionSourceValues } from '../shared/Types.js'
 */

/**
 * Emitter configuration options.
 * @typedef {object} EmitterConfig
 * @property {string} [id] Unique identifier. Defaults to a generated UUID.
 * @property {boolean} [enabled=true] Flag indicating whether the emitter is running or not.
 * @property {number} [particlesPerSecond=10] Continuous emission rate of new particles per second.
 * @property {number} [duration=Infinity] Duration of particle emission (in seconds) or JavaScript's native Infinity global object or a negative number for infinite emission.
 * @property {number} [x=0] Current horizontal coordinate of the emitter origin.
 * @property {number} [y=0] Current vertical coordinate of the emitter origin.
 * @property {string} [emissionSource=EmissionSource.VOLUME] Emission source mode, defining the geometric distribution and initial direction of emitted particles.
 * The default direction depends on both the emission source mode and the emitter type and can be overridden by specifying `particleBlueprint.direction` in the emitter config.
 * See {@link EmissionSourceValues} for available configuration constants.
 * @property {ParticleBlueprint} [particleBlueprint={}] Configuration for emitted particles.
 */

/**
 * Configuration options used by emitters to initialize particles at emission.
 * This object is not runtime Particle state and does not correspond directly to Particle properties.
 * Options are interpreted either directly or indirectly to derive Particle properties.
 * Most options may be specified as a single number or a [min, max] range array.
 * @typedef {object} ParticleBlueprint
 * @property {number|number[]} [rotation] Orientation angle (in radians).
 * @property {number|number[]} [angularVelocity] Angular rotation speed (in radians per second).
 * @property {number|number[]} [size] The visual size or scale factor. Interpreted by the renderer as pixels, radius, or a transform scale.
 * @property {Color} [color] The particle color, defined by individual RGB channels.
 * @property {number|number[]} [opacity] Transparency (0.0 = fully transparent, 1.0 = fully opaque).
 * @property {number|number[]} [lifespan] Maximum allowed lifespan (in seconds).
 * @property {number|number[]} [speed] Speed (in pixels per second) used to derive the particle's initial horizontal and vertical velocity.
 * @property {number|number[]} [direction] Movement direction angle (in radians) used to derive the particle's initial horizontal and vertical velocity.
 */

/**
 * Abstract base class for particle emitters.
 * @abstract
 * @class
 */
export class Emitter {
    /**
     * Continuous emission rate of new particles per second.
     * @type {number}
     */
    particlesPerSecond;

    /**
     * Current horizontal coordinate of the emitter origin.
     * @type {number}
     */
    x;

    /**
     * Current vertical coordinate of the emitter origin.
     * @type {number}
     */
    y;

    /**
     * Emission source mode, defining the geometric distribution and initial direction of emitted particles.
     * The default direction depends on both the emission source mode and the emitter type and can be overridden by specifying
     * `particleBlueprint.direction` in the emitter config.
     * @see {@link EmissionSourceValues} for available configuration constants.
     * @type {string}
     */
    emissionSource;

    /**
     * Internal state of the emitter's unique identifier. Defaults to a generated UUID.
     * @type {string}
     */
    #id;

    /**
     * Duration of particle emission (in seconds), where Infinity and negative numbers represent infinite emission.
     * @type {number}
     */
    #duration;

    /**
     * Internal state of the flag indicating whether the emitter is running or not.
     * @type {boolean}
     */
    #enabled;

    /**
     * Configuration settings used to initialize emitted particles.
     * @type {ParticleBlueprint}
     */
    #particleBlueprint;

    /**
     * Leftover fractional particles to be emitted between frames.
     * @type {number}
     */
    #accumulator;

    /**
     * Total elapsed running time of the emitter (in seconds).
     * @type {number}
     */
    #elapsedTime;

    /**
     * Shared reference to the emitter's visual modifier array.
     * @type {Array<Modifier>}
     */
    #visualModifiers;

    /**
     * Shared reference to the emitter's path modifier array.
     * @type {Array<Modifier>}
     */
    #pathModifiers;

    /**
     * Shared reference to the emitter's scoped emitter-specific force array.
     * @type {Array<Force>}
     */
    #scopedForces;

    /**
     * Initializes a particle emitter.
     * @constructor
     * @param {EmitterConfig} [config={}] Emitter configuration options.
     * @throws {TypeError}
     */
    constructor(config = {}) {
        if (new.target === Emitter) {
            throw new TypeError('[Gnist] Cannot instantiate abstract class Emitter directly.');
        }

        this.#id = config.id ?? crypto.randomUUID();
        this.#enabled = config.enabled ?? true;
        this.particlesPerSecond = config.particlesPerSecond ?? 10;

        this.#duration = (config.duration === Infinity || config.duration < 0) ? -1 : (config.duration ?? -1);

        this.x = config.x ?? 0;
        this.y = config.y ?? 0;
        this.emissionSource = config.emissionSource ?? EmissionSource.VOLUME;

        this.#particleBlueprint = config.particleBlueprint ?? {};

        this.#accumulator = 0;
        this.#elapsedTime = 0;

        this.#visualModifiers = [];
        this.#pathModifiers = [];
        this.#scopedForces = [];
    }

    /**
     * Unique identifier. Defaults to a generated UUID.
     * @type {string}
     * @readonly
     */
    get id() {
        return this.#id;
    }

    /**
     * Flag indicating whether the emitter is running or not.
     * @type {boolean}
     * @readonly
     */
    get enabled() {
        return this.#enabled;
    }

    /**
     * Finds a registered modifier by its unique identifier.
     * @param {string} id The unique identifier of the target modifier.
     * @returns {Modifier|null} The modifier instance if found, null otherwise.
     */
    getModifier(id) {
        return this.#visualModifiers.find(m => m.id === id) ||
               this.#pathModifiers.find(m => m.id === id) ||
               null;
    }

    /**
     * Registers a modifier to be applied to the particles emitted by the emitter.
     * @param {Modifier} modifier Modifier instance to register.
     * @returns {void}
     * @throws {Error}
     */
    addModifier(modifier) {
        const category = modifier.constructor.category;

        switch (category) {
            case ModifierCategory.VISUAL:
                this.#visualModifiers.push(modifier);
                break;
            case ModifierCategory.PATH:
                this.#pathModifiers.push(modifier);
                break;
            default:
                throw new Error(`[Gnist] Unknown modifier category: "${category}"`);
        }
    }

    /**
     * Removes a modifier from any of the emitter's registered modifier lists by its unique identifier.
     * @param {string} id The unique identifier of the target modifier.
     * @returns {boolean} True if found and successfully removed, false otherwise.
     */
    removeModifier(id) {
        const initialLength = this.#visualModifiers.length + this.#pathModifiers.length;

        this.#visualModifiers = this.#visualModifiers.filter(m => m.id !== id);
        this.#pathModifiers = this.#pathModifiers.filter(m => m.id !== id);

        return this.#visualModifiers.length + this.#pathModifiers.length < initialLength;
    }

    /**
     * Finds a registered scoped emitter-specific force by its unique identifier.
     * @param {string} id The unique identifier of the target force.
     * @returns {Force|null} The force instance if found, null otherwise.
     */
    getScopedForce(id) {
        return this.#scopedForces.find(f => f.id === id) ?? null;
    }

    /**
     * Registers a scoped emitter-specific force to be applied to the particles emitted by the emitter.
     * @param {Force} force Force instance to register.
     * @returns {void}
     */
    addScopedForce(force) {
        this.#scopedForces.push(force);
    }

    /**
     * Removes a scoped emitter-specific force from the emitter's registered forces lists by its unique identifier.
     * @param {string} id The unique identifier of the target force.
     * @returns {boolean} True if found and successfully removed, false otherwise.
     */
    removeScopedForce(id) {
        const initialLength = this.#scopedForces.length;

        this.#scopedForces = this.#scopedForces.filter(f => f.id !== id);

        return this.#scopedForces.length < initialLength;
    }

    /**
     * Starts or forcefully restarts particle emission from the beginning.
     * Resets internal tracking and sets the emitter to an active state.
     * @returns {void}
     */
    start() {
        this.#enabled = true;
        this.#elapsedTime = 0;
        this.#accumulator = 0;
    }

    /**
     * Temporarily halts particle emission and locks internal tracking.
     * @returns {void}
     */
    pause() {
        this.#enabled = false;
    }

    /**
     * Resumes particle emission and internal tracking from where they were paused.
     * @returns {void}
     */
    resume() {
        this.#enabled = true;
    }

    /**
     * Halts particle emission and resets internal tracking.
     * The emitter is deactivated but remains ready to be started again.
     * @returns {void}
     */
    stop() {
        this.#enabled = false;
        this.#elapsedTime = 0;
        this.#accumulator = 0;
    }

    /**
     * Updates the emitter's internal timer and returns any new particles to be emitted in the current frame.
     * @param {number} dt Time elapsed since the last frame (in seconds).
     * @param {Array<Particle>} particlePool Reference to the internal collection of active particles in the main {Gnist} class.
     * @returns {void}
     */
    update(dt, particlePool) {
        if (!this.#enabled) {
            return;
        }

        if (this.#duration > 0) {
            this.#elapsedTime += dt;
            if (this.#elapsedTime >= this.#duration) {
                this.stop();
                return;
            }
        }

        this.#accumulator += dt * this.particlesPerSecond;
        const spawnCount = Math.floor(this.#accumulator);
        this.#accumulator -= spawnCount;

        for (let i = 0; i < spawnCount; i++) {
            const particle = new Particle();

            this._initParticle(particle);

            particle.visualModifiers = this.#visualModifiers;
            particle.pathModifiers = this.#pathModifiers;
            particle.scopedForces = this.#scopedForces;

            particlePool.push(particle);
        }
    }

    /**
     * Sets up a particle's movement, visuals, and lifecycle state.
     * @ignore
     * @param {Particle} particle Particle instance to initialize.
     * @returns {void}
     */
    _initParticle(particle) {
        // Blueprint values may be specified either as an explicit value or as a range.
        // Ranges are resolved to a single random value via this.#resolveNumber().

        const blueprint = this.#particleBlueprint;

        this.#initParticleVelocity(particle);

        particle.rotation = this.#resolveNumber(blueprint.rotation, particle.rotation);
        particle.angularVelocity = this.#resolveNumber(blueprint.angularVelocity, particle.angularVelocity);

        particle.size = this.#resolveNumber(blueprint.size, particle.size);
        particle.baseSize = particle.size;

        const pColor = particle.color;
        const bColor = blueprint.color;
        pColor.r = Math.max(0, Math.min(255, bColor?.r ?? 255));
        pColor.g = Math.max(0, Math.min(255, bColor?.g ?? 255));
        pColor.b = Math.max(0, Math.min(255, bColor?.b ?? 255));

        particle.opacity = Math.max(0, Math.min(1, this.#resolveNumber(blueprint.opacity, particle.opacity)));

        particle.age = 0;
        particle.lifespan = this.#resolveNumber(blueprint.lifespan, particle.lifespan);
        particle.alive = true;
    }

    /**
     * Calculates the default emission direction angle based on the emitter geometry and emission source mode.
     * This is a fallback value when no explicit `direction` was specified in the emitter config's `particleBlueprint`.
     * @ignore
     * @param {Particle} [_particle] Particle instance. Subclasses may use this when calculating the direction.
     * @returns {number} The default emission direction angle (in radians).
     */
    _getDefaultDirection(_particle) {
        return 0;
    }

    /**
     * Sets up a particle's horizontal and vertical velocity components using the `speed` and `direction` values
     * specified in the emitter config's `particleBlueprint`. If no explicit `direction` was specified, it falls back
     * to the emitter's shape-specific direction.
     * @param {Particle} particle Particle instance to initialize.
     * @returns {void}
     */
    #initParticleVelocity(particle) {
        const blueprint = this.#particleBlueprint;

        const speed = this.#resolveNumber(blueprint.speed, 50);
        const direction = this.#resolveNumber(blueprint.direction, this._getDefaultDirection(particle));

        particle.vx = Math.cos(direction) * speed;
        particle.vy = Math.sin(direction) * speed;
    }

    /**
     * Resolves an input - usually a property - into a single number, picking a random value if a [min, max] range array is provided.
     * @param {number|Array<number>} value Number or [min, max] range array to resolve.
     * @param {number} defaultValue Fallback value to use if the property is neither a number nor a valid [min, max] range array.
     * @returns {number} Resolved numeric value.
     */
    #resolveNumber(value, defaultValue) {
        if (typeof value === 'number') {
            return value;
        }

        if (Array.isArray(value) && value.length === 2) {
            const min = value[0];
            const max = value[1];

            if (typeof min === 'number' && typeof max === 'number') {
                return min + Math.random() * (max - min);
            }
        }

        return defaultValue;
    }
}