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.
     * @type {string}
     * @returns {string}
     */
    static get VERSION() {
        return '0.2.0';
    }

    /**
     * Internal collection of registered emitters.
     * @type {Array<Emitter>}
     */
    #emitters;

    /**
     * Internal collection of registered global environmental forces
     * @type {Array<Force>}
     */
    #globalForces;

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

    /**
     * Internal state of the optional region used for particle culling.
     * @type {CullingBounds|null}
     */
    #cullingBounds;

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

        this.cullingBounds = config.cullingBounds;
    }

    /**
     * Registered emitters emitting active particles.
     * @type {Array<Emitter>}
     * @readonly
     */
    get emitters() {
        return this.#emitters;
    }

    /**
     * Registered global environmental forces affecting all active particles.
     * @type {Array<Force>}
     * @readonly
     */
    get globalForces() {
        return this.#globalForces;
    }

    /**
     * Common pool of active particles.
     * @type {Array<Particle>}
     * @readonly
     */
    get particles() {
        return this.#particles;
    }

    /**
     * Optional region used for particle culling.
     * @type {CullingBounds|null}
     */
    get cullingBounds() {
        return this.#cullingBounds;
    }

    /**
     * Sets the optional region used for particle culling.
     * @param {CullingBounds|null} cullingBounds The new region or null to disable culling.
     * @throws {Error}
     */
    set cullingBounds(cullingBounds) {
        if (!cullingBounds) {
            this.#cullingBounds = null;
            return;
        }

        const xMin = cullingBounds.xMin ?? -10_000_000;
        const yMin = cullingBounds.yMin ?? -10_000_000;
        const xMax = cullingBounds.xMax ?? 10_000_000;
        const yMax = cullingBounds.yMax ?? 10_000_000;

        if (xMin > xMax || yMin > yMax) {
            throw new Error('[Gnist] Invalid culling bounds: xMin must be less than or equal to xMax and yMin must be less than or equal to yMax.');
        }

        this.#cullingBounds = { xMin, yMin, xMax, yMax };
    }

    /**
     * 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) {
        return this.#emitters.find(em => em.id === id) ?? 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;
    }

    /**
     * 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) {
        return this.#globalForces.find(f => f.id === id) ?? 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;
    }

    /**
     * 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;
        }

        // Cap maximum step size to preserve physics stability during tab switches
        const safeDt = Math.min(dt, 0.1);

        this.#emitParticles(safeDt);
        this.#tickParticles(safeDt);
    }

    /**
     * Fills a provided TypedArray with particle data for WebGL.
     * @param {Float32Array} targetArray - The array to write data into.
     * @returns {number} The number of particles written.
     */
    fillFlatArray(targetArray) {
        let offset = 0;
        const count = this.#particles.length;

        for (let i = 0; i < count; i++) {
            const p = this.particles[i];

            if (!p.alive) {
                continue;
            }

            // Physical attributes
            targetArray[offset++] = p.x;
            targetArray[offset++] = p.y;
            targetArray[offset++] = p.size;
            targetArray[offset++] = p.rotation;

            // Visuals (normalized for WebGL)
            targetArray[offset++] = p.color.r / 255;
            targetArray[offset++] = p.color.g / 255;
            targetArray[offset++] = p.color.b / 255;
            targetArray[offset++] = p.opacity;
        }

        return offset / 8;
    }

    /**
     * Iterates through registered emitters to emit new particles.
     * @param {number} dt Time elapsed since the last frame (in seconds).
     * @returns {void}
     */
    #emitParticles(dt) {
        const particlePool = this.#particles;
        const emitterCount = this.#emitters.length;

        for (let i = 0; i < emitterCount; i++) {
            const emitter = this.#emitters[i];
            if (emitter) {
                emitter.update(dt, particlePool);
            }
        }
    }

    /**
     * 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.#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);

                // 1. Environmental forces

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

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

                // 2. Path modifiers (must run BEFORE position integration so vx/vy changes apply immediately)

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

                // 3. Position integration

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

                // 4. Visual Modifiers (must run AFTER position integration)

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

                // 5. Culling

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

                    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;
    }
}