UNPKG

littlejsengine

Version:

LittleJS - Tiny and Fast HTML5 Game Engine

523 lines (485 loc) 22.5 kB
/** * LittleJS Particle System * - A simple but fast and flexible particle system * - Lightweight Particles are created and managed by ParticleEmitters * - The particle design tool can be used to help create emitters * @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); } }