core/Gnist.js

import { Emitter } from '../emitters/Emitter.js';
import { Force } from '../forces/Force.js';

import { Particle } from './Particle.js';

/**
 * Engine configuration options.
 * @typedef {object} EngineConfig
 * @property {CullingBounds|null} [cullingBounds=null] Optional region used for particle culling.
 */

/**
 * Defines a region beyond which particles are considered outside the simulation and are marked dead.
 * A safety margin is applied per particle based on its position and size, preventing early removal while it is still
 * partially inside the region.
 * @typedef {object} CullingBounds
 * @property {number} xMin Left boundary of the region.
 * @property {number} yMin Top boundary of the region.
 * @property {number} xMax Right boundary of the region.
 * @property {number} yMax Bottom boundary of the region.
 */

/**
 * The core particle engine that manages the simulation pipeline and particle lifecycle.
 * @class
 */
export class Gnist {
    /**
     * The current semantic version of the Gnist particle engine.
     * @returns {string}
     */
    static get VERSION() {
        return '0.0.0';
    }

    /**
     * Constant value representing an infinite particle lifespan.
     * @type {number}
     */
    static get INFINITE_DURATION() {
        return -1;
    }

    /**
     * Engine configuration options.
     * @type {EngineConfig}
     */
    config;

    /**
     * Registered emitters emitting active particles.
     * @type {Array<Emitter>}
     */
    #emitters;

    /**
     * Registered global environmental forces affecting all active particles.
     * @type {Array<Force>}
     */
    #globalForces;

    /**
     * Common pool of active particles.
     * @type {Array<Particle>}
     */
    #particles;

    /**
     * Initializes an empty simulation pipeline.
     * @constructor
     * @param {EngineConfig} [config={}] Engine configuration options.
     */
    constructor(config = {}) {
        this.#emitters = [];
        this.#globalForces = [];
        this.#particles = [];

        this.config = {};
        this.config.cullingBounds = config.cullingBounds ?? null;
    }

    /**
     * Gets the current list of registered emitters.
     * @returns {Array<Emitter>}
     */
    getEmitters() {
        return this.#emitters;
    }

    /**
     * Finds a registered emitter by its unique identifier.
     * @param {string} id The unique identifier of the target emitter.
     * @returns {Emitter|null} The emitter instance if found, null otherwise.
     */
    getEmitter(id) {
        for (let i = 0; i < this.#emitters.length; i++) {
            if (this.#emitters[i].id === id) {
                return this.#emitters[i];
            }
        }

        return null;
    }

    /**
     * Registers an emitter into the simulation pipeline.
     * @param {Emitter} emitter The emitter instance to register.
     * @returns {this} The Gnist engine instance for method chaining.
     */
    addEmitter(emitter) {
        this.#emitters.push(emitter);

        return this;
    }

    /**
     * Removes an emitter from the simulation pipeline by its unique identifier.
     * @param {string} id The unique identifier of the target emitter.
     * @returns {boolean} True if found and successfully removed, false otherwise.
     */
    removeEmitter(id) {
        const initialLength = this.#emitters.length;

        this.#emitters = this.#emitters.filter(e => e.id !== id);

        return this.#emitters.length < initialLength;
    }

    /**
     * Gets the current list of registered global environmental forces.
     * @returns {Array<Force>}
     */
    getGlobalForces() {
        return this.#globalForces;
    }

    /**
     * Finds a registered global environmental 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.
     */
    getGlobalForce(id) {
        for (let i = 0; i < this.#globalForces.length; i++) {
            if (this.#globalForces[i].id === id) {
                return this.#globalForces[i];
            }
        }

        return null;
    }

    /**
     * Registers a global environmental force into the simulation pipeline.
     * @param {Force} force The force instance to register.
     * @returns {this} The Gnist engine instance for method chaining.
     */
    addGlobalForce(force) {
        this.#globalForces.push(force);

        return this;
    }

    /**
     * Removes a global environmental force from the simulation pipeline by its unique identifier.
     * @param {string} id The unique identifier of the target force.
     * @returns {boolean} True if found and successfully removed, false otherwise.
     */
    removeGlobalForce(id) {
        const initialLength = this.#globalForces.length;

        this.#globalForces = this.#globalForces.filter(e => e.id !== id);

        return this.#globalForces.length < initialLength;
    }

    /**
     * Gets the current list of active particles of the common particle pool.
     * @returns {Array<Particle>}
     */
    getParticles() {
        return this.#particles;
    }

    /**
     * Steps the simulation pipeline forward by a given time delta.
     * @param {number} dt Time elapsed since the last frame (in seconds).
     * @returns {void}
     */
    update(dt) {
        if (dt <= 0) {
            return;
        }

        this.emitParticles(dt);
        this.tickParticles(dt);
    }

    /**
     * Iterates through registered emitters to emit new particles.
     * @param {number} dt Time elapsed since the last frame (in seconds).
     * @returns {void}
     */
    emitParticles(dt) {
        for (let i = 0; i < this.#emitters.length; i++) {
            const newParticles = this.#emitters[i].update(dt);

            if (newParticles.length > 0) {
                this.#particles.push(...newParticles);
            }
        }
    }

    /**
     * Updates particle lifecycles, applies global and scoped emitter-specific forces, moves particles, and
     * applies modifiers.
     * @param {number} dt Time elapsed since the last frame (in seconds).
     * @returns {void}
     */
    tickParticles(dt) {
        const globalForces = this.#globalForces;
        const globalForcesCount = globalForces.length;
        const particles = this.#particles;
        const particleCount = particles.length;
        const cullingBounds = this.config.cullingBounds;

        let aliveCount = 0;

        for (let i = 0; i < particleCount; i++) {
            const particle = particles[i];

            particle.age += dt;
            if (particle.age >= particle.lifespan) {
                particle.alive = false;
            }

            if (particle.alive) {
                const normalizedAge = Math.min(particle.age / particle.lifespan, 1.0);

                for (let j = 0; j < globalForcesCount; j++) {
                    globalForces[j].apply(particle, dt);
                }

                const scopedForces = particle.getScopedForces();
                const scopedForcesCount = scopedForces.length;
                for (let j = 0; j < scopedForcesCount; j++) {
                    scopedForces[j].apply(particle, dt);
                }

                particle.x += particle.vx * dt;
                particle.y += particle.vy * dt;
                particle.rotation += particle.angularVelocity * dt;

                const activeModifiers = particle.getModifiers();
                const activeModifiersCount = activeModifiers.length;
                for (let j = 0; j < activeModifiersCount; j++) {
                    activeModifiers[j].update(particle, normalizedAge, dt);
                }

                if (cullingBounds !== null) {
                    const safetyMargin = particle.size;

                    if (particle.x < cullingBounds.xMin - safetyMargin ||
                        particle.x > cullingBounds.xMax + safetyMargin ||
                        particle.y < cullingBounds.yMin - safetyMargin ||
                        particle.y > cullingBounds.yMax + safetyMargin
                    ) {
                        particle.alive = false;
                    }
                }
            }

            // In-place dual-pointer compaction avoids Array.filter allocations, eliminating garbage collection spikes
            if (particle.alive) {
                if (aliveCount !== i) {
                    particles[aliveCount] = particle;
                }
                aliveCount++;
            }
        }

        particles.length = aliveCount;
    }
}