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