src_engineParticles.js

/**
 * LittleJS Particle System
 * - Fast and flexible particle effects system
 * - ParticleEmitter spawns and manages lightweight Particle objects
 * - Particles support color gradients, fading, rotation, and scaling
 * - Physics simulation with velocity, gravity, and damping
 * - Collision detection with tile layers
 * - Additive blending for glowing effects
 * - Cone-based emission with randomization
 * - Particle design tool available for easy emitter creation
 * @namespace Particles
 */

'use strict';

/**
 *  @callback ParticleCallback - Function that processes a particle
 *  @param {Particle} particle
 *  @memberof Particles
 */

/**
 *  @callback ParticleCollideCallback - Collide callback for particles
 *  @param {Particle} particle
 *  @param {number} tileData
 *  @param {Vector2} pos
 *  @memberof Particles
 */

/**
 * Particle Emitter - Spawns particles with the given settings
 * @extends EngineObject
 * @memberof Particles
 * @example
 * // create a particle emitter
 * let pos = vec2(2,3);
 * let particleEmitter = new ParticleEmitter
 * (
 *     pos, 0, 1, 0, 500, PI,      // pos, angle, emitSize, emitTime, emitRate, emitCone
 *     tile(0, 16),                // tileInfo
 *     rgb(1,1,1,1), rgb(0,0,0,1), // colorStartA, colorStartB
 *     rgb(1,1,1,0), rgb(0,0,0,0), // colorEndA, colorEndB
 *     1, .2, .2, .1, .05,  // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed
 *     .99, 1, 1, PI, .05,  // damping, angleDamping, gravityScale, particleCone, fadeRate,
 *     .5, 1                // randomness, collide, additive, randomColorLinear, renderOrder
 * );
 */
class ParticleEmitter extends EngineObject
{
    /** Create a particle system with the given settings
     *  @param {Vector2} pos - World space position of the emitter
     *  @param {number} [angle] - Angle to emit the particles
     *  @param {number|Vector2}  [emitSize] - World space size of the emitter (float for circle diameter, vec2 for rect)
     *  @param {number} [emitTime] - How long to stay alive (0 is forever)
     *  @param {number} [emitRate] - How many particles per second to spawn, does not emit if 0
     *  @param {number} [emitConeAngle=PI] - Local angle to apply velocity to particles from emitter
     *  @param {TileInfo} [tileInfo] - Tile info to render particles (undefined is untextured)
     *  @param {Color} [colorStartA=WHITE] - Color at start of life 1, randomized between start colors
     *  @param {Color} [colorStartB=WHITE] - Color at start of life 2, randomized between start colors
     *  @param {Color} [colorEndA=CLEAR_WHITE] - Color at end of life 1, randomized between end colors
     *  @param {Color} [colorEndB=CLEAR_WHITE] - Color at end of life 2, randomized between end colors
     *  @param {number} [particleTime]      - How long particles live
     *  @param {number} [sizeStart]         - How big are particles at start
     *  @param {number} [sizeEnd]           - How big are particles at end
     *  @param {number} [speed]             - How fast are particles when spawned
     *  @param {number} [angleSpeed]        - How fast are particles rotating
     *  @param {number} [damping]           - How much to dampen particle speed
     *  @param {number} [angleDamping]      - How much to dampen particle angular speed
     *  @param {number} [gravityScale]      - How much gravity effect particles
     *  @param {number} [particleConeAngle] - Cone for start particle angle
     *  @param {number} [fadeRate]          - How quick to fade particles at start/end in percent of life
     *  @param {number} [randomness]    - Apply extra randomness percent
     *  @param {boolean} [collideTiles] - Do particles collide against tiles
     *  @param {boolean} [additive]     - Should particles use additive blend
     *  @param {boolean} [randomColorLinear] - Should color be randomized linearly or across each component
     *  @param {number} [renderOrder] - Render order for particles (additive is above other stuff by default)
     *  @param {boolean}  [localSpace] - Should it be in local space of emitter (world space is default)
     */
    constructor
    (
        pos,
        angle,
        emitSize = 0,
        emitTime = 0,
        emitRate = 100,
        emitConeAngle = PI,
        tileInfo,
        colorStartA = WHITE,
        colorStartB = WHITE,
        colorEndA = CLEAR_WHITE,
        colorEndB = CLEAR_WHITE,
        particleTime = .5,
        sizeStart = .1,
        sizeEnd = 1,
        speed = .1,
        angleSpeed = .05,
        damping = 1,
        angleDamping = 1,
        gravityScale = 0,
        particleConeAngle = PI,
        fadeRate = .1,
        randomness = .2,
        collideTiles = false,
        additive = false,
        randomColorLinear = true,
        renderOrder = additive ? 1e9 : 0,
        localSpace = false
    )
    {
        super(pos, vec2(), tileInfo, angle, undefined, renderOrder);

        // emitter settings
        /** @property {boolean} - Should particles be emitted in a circle */
        this.emitCircle = typeof emitSize === 'number';
        /** @property {number|Vector2} - World space size of the emitter (float for circle diameter, vec2 for rect) */
        this.emitSize = typeof emitSize === 'number' ? vec2(emitSize) : emitSize.copy();
        /** @property {number} - How long to stay alive (0 is forever) */
        this.emitTime = emitTime;
        /** @property {number} - How many particles per second to spawn, does not emit if 0 */
        this.emitRate = emitRate;
        /** @property {number} - Local angle to apply velocity to particles from emitter */
        this.emitConeAngle = emitConeAngle;

        // color settings
        /** @property {Color} - Color at start of life 1, randomized between start colors */
        this.colorStartA = colorStartA.copy();
        /** @property {Color} - Color at start of life 2, randomized between start colors */
        this.colorStartB = colorStartB.copy();
        /** @property {Color} - Color at end of life 1, randomized between end colors */
        this.colorEndA = colorEndA.copy();
        /** @property {Color} - Color at end of life 2, randomized between end colors */
        this.colorEndB = colorEndB.copy();
        /** @property {boolean} - Should color be randomized linearly or across each component */
        this.randomColorLinear = randomColorLinear;

        // particle settings
        /** @property {number} - How long particles live */
        this.particleTime      = particleTime;
        /** @property {number} - How big are particles at start */
        this.sizeStart         = sizeStart;
        /** @property {number} - How big are particles at end */
        this.sizeEnd           = sizeEnd;
        /** @property {number} - How fast are particles when spawned */
        this.speed             = speed;
        /** @property {number} - How fast are particles rotating */
        this.angleSpeed        = angleSpeed;
        /** @property {number} - How much to dampen particle speed */
        this.damping           = damping;
        /** @property {number} - How much to dampen particle angular speed */
        this.angleDamping      = angleDamping;
        /** @property {number} - How much gravity affects particles */
        this.gravityScale      = gravityScale;
        /** @property {number} - Cone for start particle angle */
        this.particleConeAngle = particleConeAngle;
        /** @property {number} - How quick to fade in particles at start/end in percent of life */
        this.fadeRate          = fadeRate;
        /** @property {number} - Apply extra randomness percent */
        this.randomness        = randomness;
        /** @property {boolean} - Do particles collide against tiles */
        this.collideTiles      = collideTiles;
        /** @property {boolean} - Should particles use additive blend */
        this.additive          = additive;
        /** @property {boolean} - Should it be in local space of emitter */
        this.localSpace        = localSpace;
        /** @property {number} - If non zero the particle is drawn as a trail, stretched in the direction of velocity */
        this.trailScale        = 0;
        /** @property {ParticleCallback} - Callback when particle is created */
        this.particleCreateCallback = undefined;
        /** @property {ParticleCallback} - Callback when particle is destroyed */
        this.particleDestroyCallback = undefined;
        /** @property {ParticleCollideCallback} - Callback when particle collides */
        this.particleCollideCallback = undefined;
        /** @property {number} - Percentage of velocity to pass to particles (0-1) */
        this.velocityInheritance = 0;
        /** @property {number} - Track particle emit time */
        this.emitTimeBuffer = 0;
        /** @property {Array<Particle>} - Array of particles for this emitter */
        this.particles = [];

        // track previous position and angle
        this.previousAngle = this.angle;
        this.previousPos = this.pos.copy();
    }

    /** Update the emitter to spawn particles, called automatically by engine once each frame */
    update()
    {
        // physics sanity checks
        ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1);
        ASSERT(this.damping >= 0 && this.damping <= 1);

        if (this.velocityInheritance)
        {
            // pass emitter velocity to particles
            const p = this.velocityInheritance;
            this.velocity.x = p * (this.pos.x - this.previousPos.x);
            this.velocity.y = p * (this.pos.y - this.previousPos.y);
            this.angleVelocity = p * (this.angle - this.previousAngle);
            this.previousAngle = this.angle;
            this.previousPos.x = this.pos.x;
            this.previousPos.y = this.pos.y;
        }

        // update emitter
        if (this.isActive())
        {
            // emit particles
            if (this.emitRate && particleEmitRateScale)
            {
                const rate = 1/this.emitRate/particleEmitRateScale;
                for (this.emitTimeBuffer += timeDelta; this.emitTimeBuffer > 0; this.emitTimeBuffer -= rate)
                    this.emitParticle();
            }
        }
        else if (this.particles.length === 0)
            this.destroy(true);
            
        // update and remove destroyed particles
        this.particles = this.particles.filter((p)=>
        {
            p.update();
            return !p.destroyed;
        });

        if (debugParticles)
        {
            // show emitter bounds
            if (this.emitCircle)
                debugCircle(this.pos, this.emitSize.x/2, '#0f0');
            else
                debugRect(this.pos, this.emitSize, '#0f0', 0, this.angle);
        }
    }

    /** Spawn one particle
     *  @return {Particle} */
    emitParticle()
    {
        // spawn a particle
        let pos = this.emitCircle ?            // check if circle emitter
            randInCircle(this.emitSize.x/2)    // circle emitter
            : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
                .multiply(this.emitSize).rotate(this.angle)
        let angle = rand(this.particleConeAngle, -this.particleConeAngle);
        if (!this.localSpace)
        {
            pos.x += this.pos.x;
            pos.y += this.pos.y;
            angle += this.angle;
        }

        // randomness scales each parameter by a percentage
        const randomness = this.randomness;
        const randomizeScale = (v)=> v + v*rand(randomness, -randomness);

        // randomize particle settings
        const particleTime  = randomizeScale(this.particleTime);
        const sizeStart     = randomizeScale(this.sizeStart);
        const sizeEnd       = randomizeScale(this.sizeEnd);
        const speed         = randomizeScale(this.speed);
        const angleSpeed    = randomizeScale(this.angleSpeed) * randSign();
        const coneAngle     = rand(this.emitConeAngle, -this.emitConeAngle);
        const colorStart    = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear);
        const colorEnd      = randColor(this.colorEndA,   this.colorEndB, this.randomColorLinear);
        const velocityAngle = this.localSpace ? coneAngle : this.angle + coneAngle;

        // build particle
        const velocity = vec2(speed*sin(velocityAngle), speed*cos(velocityAngle));
        let angleVelocity = angleSpeed;
        if (!this.localSpace && this.velocityInheritance > 0)
        {
            // apply emitter velocity to particle
            velocity.x += this.velocity.x;
            velocity.y += this.velocity.y;
            angleVelocity += this.angleVelocity;
        }
        const particle = new Particle(this, pos, angle, colorStart, colorEnd, particleTime, sizeStart, sizeEnd, velocity, angleVelocity);
        this.particles.push(particle);

        // call particle create callback
        this.particleCreateCallback?.(particle);

        // return the newly created particle
        return particle;
    }

    /** Particle emitters do not have physics */
    updatePhysics() {}

    /** Render all particles for this emitter */
    render()
    {
        // render all particles
        for (const particle of this.particles)
            particle.render();
    }

    /** is emitter actively spawning */
    isActive() { return !this.emitTime || this.getAliveTime() < this.emitTime; }

    /** Destroy the particle emitter
     *  @param {boolean} [immediate] - should particle emitters and other attached effects be allowed to die off */
    destroy(immediate=false)
    {
        if (this.destroyed) return;

        super.destroy(immediate);
        if (!immediate && this.particles.length > 0)
        {
            // wait for particles to die off
            this.destroyed = false;
            this.emitTime = -1;
        }
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Particle Object - Created automatically by Particle Emitters
 * @memberof Particles
 */
class Particle 
{
    /**
     * Create a particle with the passed in settings
     * Typically this is created automatically by a ParticleEmitter
     * @param {ParticleEmitter} emitter - The emitter that created this particle
     * @param {Vector2} pos             - World or local space position
     * @param {number}  angle           - Angle of the particle
     * @param {Color}   colorStart      - Color at start of life
     * @param {Color}   colorEnd        - Color at end of life
     * @param {number}  lifeTime        - How long to live for
     * @param {number}  sizeStart       - Size at start of life
     * @param {number}  sizeEnd         - Size at end of life
     * @param {Vector2} [velocity]      - Velocity of the particle
     * @param {number}  [angleVelocity] - Angular speed of the particle
     */
    constructor(emitter, pos, angle, colorStart, colorEnd, lifeTime, sizeStart, sizeEnd, velocity = vec2(), angleVelocity = 0)
    {
        /** @property {ParticleEmitter} */
        this.emitter = emitter;
        /** @property {Vector2} */
        this.pos = pos;
        /** @property {number} */
        this.angle = angle;
        /** @property {Vector2} */
        this.size = vec2(sizeStart);
        /** @property {Color} */
        this.color = colorStart.copy();
        /** @property {Color} */
        this.colorStart = colorStart;
        /** @property {Color} */
        this.colorEnd = colorEnd;
        /** @property {number} */
        this.lifeTime = lifeTime;
        /** @property {number} */
        this.sizeStart = sizeStart;
        /** @property {number} */
        this.sizeEnd = sizeEnd;
        /** @property {Vector2} */
        this.velocity = velocity;
        /** @property {number} */
        this.angleVelocity = angleVelocity;
        /** @property {number} */
        this.spawnTime = time;
        /** @property {boolean} */
        this.mirror = randBool();
        /** @property {EngineObject} */
        this.groundObject = undefined;
        /** @property {boolean} */
        this.destroyed = false;
        /** @property {TileInfo} */
        this.tileInfo = emitter.tileInfo;
    }

    /** Update the particle */
    update()
    {
        // emitter properties
        const emitter = this.emitter;
        const damping = emitter.damping;
        const angleDamping = emitter.angleDamping;
        const restitution = emitter.restitution;
        const friction = emitter.friction;
        const gravityScale = emitter.gravityScale;
        const collideTiles = emitter.collideTiles;
        const collideCallback = emitter.particleCollideCallback;

        // destroy particle when its time runs out
        if (this.lifeTime > 0 && time - this.spawnTime > this.lifeTime)
        {
            this.destroy();
            return;
        }

        // apply physics
        const oldPos = this.pos.copy();
        this.velocity.x *= damping;
        this.velocity.y *= damping;
        this.pos.x += this.velocity.x += gravity.x * gravityScale;
        this.pos.y += this.velocity.y += gravity.y * gravityScale;
        this.angle += this.angleVelocity *= angleDamping;

        // don't do collision if solver disabled
        if (!enablePhysicsSolver || !collideTiles) return;
        
        // apply max circular speed to prevent going through collision
        const length2 = this.velocity.lengthSquared();
        if (length2 > objectMaxSpeed*objectMaxSpeed)
        {
            const s = objectMaxSpeed / length2**.5;
            this.velocity.x *= s;
            this.velocity.y *= s;
        }

        // check collision against tiles
        this.groundObject = undefined;
        const testCollision = collideCallback ? (pos)=>
        {
            const data = tileCollisionGetData(pos);
            return data && collideCallback(this, data, pos);
        } : (pos)=> tileCollisionGetData(pos) > 0;

        if (testCollision(this.pos))
        {
            // if already was stuck in collision, don't do anything
            const hitLayer = tileCollisionTest(this.pos);
            if (!testCollision(oldPos))
            {
                if (!collideCallback || collideCallback?.(this, hitLayer))
                {
                    // test which side we bounced off (or both if a corner)
                    const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
                    const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
                    const hitRestitution = max(restitution, hitLayer.restitution);
                    const hitFriction = max(friction, hitLayer.friction);
                    if (isBlockedX)
                    {
                        // move to previous X position and bounce
                        this.pos.x = oldPos.x;
                        this.velocity.x *= -hitRestitution;
                        this.velocity.y *= hitFriction;
                    }
                    if (isBlockedY || !isBlockedX)
                    {
                        const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
                        if (wasFalling)
                            this.groundObject = hitLayer;

                        // move to previous Y position and bounce
                        this.pos.y = oldPos.y;
                        this.velocity.y *= -hitRestitution;
                        this.velocity.x *= hitFriction;
                    }
                    debugPhysics && debugRect(this.pos, this.size, '#f00');
                }
            }
        }
    }

    /** Destroy this particle */
    destroy()
    {
        const destroyCallback = this.emitter.particleDestroyCallback;
        const c = this.colorEnd;
        this.color.set(c.r, c.g, c.b, c.a);
        this.size.set(this.sizeEnd, this.sizeEnd);
        this.destroyed = true;
        destroyCallback?.(this);
    }

    /** Render the particle, automatically called each frame */
    render()
    {
        // emitter properties
        const emitter = this.emitter;
        const localSpace = emitter.localSpace;
        const additive = emitter.additive;
        const trailScale = emitter.trailScale;
        const fadeRate = emitter.fadeRate / 2;

        // lerp color and size
        const p1 = this.lifeTime > 0 ? min((time - this.spawnTime) / this.lifeTime, 1) : 1, p2 = 1-p1;
        const radius = p2 * this.sizeStart + p1 * this.sizeEnd;
        const size = vec2(radius);
        const alphaFade = p1 < fadeRate ? p1/fadeRate : 
            p1 > 1-fadeRate ? (1-p1)/fadeRate : 1;
        this.color.r = p2 * this.colorStart.r + p1 * this.colorEnd.r;
        this.color.g = p2 * this.colorStart.g + p1 * this.colorEnd.g;
        this.color.b = p2 * this.colorStart.b + p1 * this.colorEnd.b;
        this.color.a = (p2 * this.colorStart.a + p1 * this.colorEnd.a) * alphaFade;

        // draw the particle
        additive && setBlendMode(true);

        // update the position and angle for drawing
        const pos = this.pos.copy();
        let angle = this.angle;
        if (localSpace)
        {
            // in local space of emitter
            const a = emitter.angle;
            const c = cos(a), s = sin(a);
            pos.set(emitter.pos.x + pos.x*c - pos.y*s, 
                emitter.pos.y + pos.x*s + pos.y*c);
            angle += a;
        }
        if (trailScale)
        {
            // trail style particles
            const velocity = localSpace ? 
                this.velocity.rotate(-emitter.angle) : this.velocity;
            const speed = velocity.length();
            if (speed)
            {
                // stretch in direction of motion
                const trailLength = speed * trailScale;
                size.y = max(size.x, trailLength);
                angle = atan2(velocity.x, velocity.y);
                drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror);
            }
        }
        else
            drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror);
        additive && setBlendMode();
        debugParticles && debugRect(pos, size, '#f005', 0, angle);
    }
}