From c2cb1694946769f04214a77d91acf0145e65b57f Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Mon, 15 Jul 2024 10:37:53 -0500 Subject: [PATCH] refactor: Improve CPU Particles API + Perf (#3118) This PR relates to the v1 milestone for refactoring particles to be a more modern excalibur look and feel. Additionally this PR also improves CPU particle performance significantly by leveraging pooling --- CHANGELOG.md | 40 ++- sandbox/src/game.ts | 44 ++- sandbox/tests/emitter/index.ts | 45 +-- src/engine/Debug/DebugSystem.ts | 2 +- src/engine/Graphics/GraphicsSystem.ts | 6 +- src/engine/ParticleEmitter.ts | 331 --------------------- src/engine/Particles.ts | 293 ------------------ src/engine/Particles/ParticleEmitter.ts | 184 ++++++++++++ src/engine/Particles/Particles.ts | 300 +++++++++++++++++++ src/engine/Particles/tsconfig.json | 6 + src/engine/index.ts | 4 +- src/spec/ParticleSpec.ts | 200 +++++++------ src/spec/images/ParticleSpec/Particles.png | Bin 11580 -> 13384 bytes src/spec/images/ParticleSpec/parented.png | Bin 15186 -> 19441 bytes src/stories/ParticleEmitter.stories.ts | 44 +-- 15 files changed, 706 insertions(+), 793 deletions(-) delete mode 100644 src/engine/ParticleEmitter.ts delete mode 100644 src/engine/Particles.ts create mode 100644 src/engine/Particles/ParticleEmitter.ts create mode 100644 src/engine/Particles/Particles.ts create mode 100644 src/engine/Particles/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c9de937..7be4fabf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,42 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Breaking Changes -- +- `ex.Particle` and `ex.ParticleEmitter` now have an API that looks like modern Excalibur APIs + * `particleSprite` is renamed to `graphic` + * `particleRotationalVelocity` is renamed to `angularVelocity` + * `fadeFlag` is renamed to `fade` + * `acceleration` is renamed to `acc` + * `particleLife` is renamed to `life` + * `ParticleEmitter` now takes a separate `particle: ParticleConfig` parameter to disambiguate between particles parameters and emitter ones + ```typescript + const emitter = new ex.ParticleEmitter({ + width: 10, + height: 10, + radius: 5, + emitterType: ex.EmitterType.Rectangle, + emitRate: 300, + isEmitting: true, + particle: { + transform: ex.ParticleTransform.Global, + opacity: 0.5, + life: 1000, + acc: ex.vec(10, 80), + beginColor: ex.Color.Chartreuse, + endColor: ex.Color.Magenta, + startSize: 5, + endSize: 100, + minVel: 100, + maxVel: 200, + minAngle: 5.1, + maxAngle: 6.2, + fade: true, + maxSize: 10, + graphic: swordImg.toSprite(), + randomRotation: true, + minSize: 1 + } + }); + ``` ### Deprecated @@ -44,6 +79,9 @@ are doing mtv adjustments during precollision. ### Updates +- Perf improvements to `ex.ParticleEmitter` + * Use the same integrator as the MotionSystem in the tight loop + * Leverage object pools to increase performance and reduce allocations - Perf improvements to collision narrowphase and solver steps * Working in the local polygon space as much as possible speeds things up * Add another pair filtering condition on the `SparseHashGridCollisionProcessor` which reduces pairs passed to narrowphase diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index 6bfa3fff6..306e18d52 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -915,33 +915,29 @@ game.add(player); // Add particle emitter // sprite.anchor = new ex.Vector(0.5, 0.5); var emitter = new ex.ParticleEmitter({ + isEmitting: false, + emitRate: 494, pos: new ex.Vector(100, 300), width: 2, height: 2, - minVel: 417, - maxVel: 589, - minAngle: Math.PI, - maxAngle: Math.PI * 2, - isEmitting: false, - emitRate: 494, - opacity: 0.84, - fadeFlag: true, - particleLife: 2465, - maxSize: 20.5, - minSize: 10, - acceleration: new ex.Vector(0, 460), - beginColor: ex.Color.Red, - endColor: ex.Color.Yellow, - particleSprite: blockSprite, - particleRotationalVelocity: Math.PI / 10, - randomRotation: true -}); -const original = (ex.ParticleEmitter.prototype as any)._createParticle; -(ex.ParticleEmitter.prototype as any)._createParticle = function () { - const particle = original.call(this); - particle.graphics.onPostDraw = () => {}; - return particle; -}; + particle: { + minVel: 417, + maxVel: 589, + minAngle: Math.PI, + maxAngle: Math.PI * 2, + opacity: 0.84, + fade: true, + life: 2465, + maxSize: 20.5, + minSize: 10, + beginColor: ex.Color.Red, + endColor: ex.Color.Yellow, + graphic: blockSprite, + angularVelocity: Math.PI / 10, + acc: new ex.Vector(0, 460), + randomRotation: true + } +}); game.add(emitter); var exploding = false; diff --git a/sandbox/tests/emitter/index.ts b/sandbox/tests/emitter/index.ts index 737f864d0..c9832f74e 100644 --- a/sandbox/tests/emitter/index.ts +++ b/sandbox/tests/emitter/index.ts @@ -1,8 +1,11 @@ var game = new ex.Engine({ width: 400, - height: 400 + height: 400, + displayMode: ex.DisplayMode.FitScreenAndFill }); +var swordImg = new ex.ImageSource('https://cdn.rawgit.com/excaliburjs/Excalibur/7dd48128/assets/sword.png'); + var actor = new ex.Actor({ anchor: ex.vec(0.5, 0.5), pos: game.screen.center, @@ -18,28 +21,32 @@ actor.actions.repeatForever((ctx) => { var emitter = new ex.ParticleEmitter({ width: 10, height: 10, - emitterType: ex.EmitterType.Rectangle, - particleTransform: ex.ParticleTransform.Global, radius: 5, - minVel: 100, - maxVel: 200, - minAngle: 5.1, - maxAngle: 6.2, + emitterType: ex.EmitterType.Rectangle, emitRate: 300, - opacity: 0.5, - fadeFlag: true, - particleLife: 1000, - maxSize: 10, - minSize: 1, - startSize: 5, - endSize: 100, - acceleration: ex.vec(10, 80), - beginColor: ex.Color.Chartreuse, - endColor: ex.Color.Magenta + isEmitting: true, + particle: { + transform: ex.ParticleTransform.Global, + opacity: 0.5, + life: 1000, + acc: ex.vec(10, 80), + beginColor: ex.Color.Chartreuse, + endColor: ex.Color.Magenta, + startSize: 5, + endSize: 100, + minVel: 100, + maxVel: 200, + minAngle: 5.1, + maxAngle: 6.2, + fade: true, + maxSize: 10, + graphic: swordImg.toSprite(), + randomRotation: true, + minSize: 1 + } }); -emitter.isEmitting = true; -game.start(); +game.start(new ex.Loader([swordImg])); game.add(actor); actor.angularVelocity = 2; diff --git a/src/engine/Debug/DebugSystem.ts b/src/engine/Debug/DebugSystem.ts index 2208db538..84c678bd2 100644 --- a/src/engine/Debug/DebugSystem.ts +++ b/src/engine/Debug/DebugSystem.ts @@ -12,7 +12,7 @@ import { BodyComponent } from '../Collision/BodyComponent'; import { CollisionSystem } from '../Collision/CollisionSystem'; import { CompositeCollider } from '../Collision/Colliders/CompositeCollider'; import { GraphicsComponent } from '../Graphics/GraphicsComponent'; -import { Particle } from '../Particles'; +import { Particle } from '../Particles/Particles'; import { DebugGraphicsComponent } from '../Graphics/DebugGraphicsComponent'; import { CoordPlane } from '../Math/coord-plane'; import { Debug } from '../Graphics/Debug'; diff --git a/src/engine/Graphics/GraphicsSystem.ts b/src/engine/Graphics/GraphicsSystem.ts index 04e3fd67a..61f9925fe 100644 --- a/src/engine/Graphics/GraphicsSystem.ts +++ b/src/engine/Graphics/GraphicsSystem.ts @@ -8,7 +8,6 @@ import { Camera } from '../Camera'; import { Query, System, SystemPriority, SystemType, World } from '../EntityComponentSystem'; import { Engine } from '../Engine'; import { GraphicsGroup } from './GraphicsGroup'; -import { Particle } from '../Particles'; // this import seems to bomb wallaby import { ParallaxComponent } from './ParallaxComponent'; import { CoordPlane } from '../Math/coord-plane'; import { BodyComponent } from '../Collision/BodyComponent'; @@ -142,10 +141,7 @@ export class GraphicsSystem extends System { } entity.events.emit('predraw', new PreDrawEvent(this._graphicsContext, delta, entity)); - // TODO remove this hack on the particle redo - // Remove this line after removing the wallaby import - const particleOpacity = entity instanceof Particle ? entity.opacity : 1; - this._graphicsContext.opacity *= graphics.opacity * particleOpacity; + this._graphicsContext.opacity *= graphics.opacity; // Draw the graphics component this._drawGraphicsComponent(graphics, transform); diff --git a/src/engine/ParticleEmitter.ts b/src/engine/ParticleEmitter.ts deleted file mode 100644 index ac60681a8..000000000 --- a/src/engine/ParticleEmitter.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { Engine } from './Engine'; -import { Actor } from './Actor'; -import { Color } from './Color'; -import { Vector, vec } from './Math/vector'; -import * as Util from './Util/Util'; -import { Random } from './Math/Random'; -import { CollisionType } from './Collision/CollisionType'; -import { randomInRange } from './Math/util'; -import { Graphic } from './Graphics'; -import { EmitterType } from './EmitterType'; -import { Particle, ParticleTransform, ParticleEmitterArgs } from './Particles'; - -/** - * Using a particle emitter is a great way to create interesting effects - * in your game, like smoke, fire, water, explosions, etc. `ParticleEmitter` - * extend [[Actor]] allowing you to use all of the features that come with. - */ -export class ParticleEmitter extends Actor { - private _particlesToEmit: number = 0; - - public numParticles: number = 0; - - /** - * Random number generator - */ - public random: Random; - - /** - * Gets or sets the isEmitting flag - */ - public isEmitting: boolean = true; - /** - * Gets or sets the backing particle collection - */ - public particles: Particle[] = []; - - /** - * Gets or sets the backing deadParticle collection - */ - public deadParticles: Particle[] = []; - - /** - * Gets or sets the minimum particle velocity - */ - public minVel: number = 0; - /** - * Gets or sets the maximum particle velocity - */ - public maxVel: number = 0; - - /** - * Gets or sets the acceleration vector for all particles - */ - public acceleration: Vector = new Vector(0, 0); - - /** - * Gets or sets the minimum angle in radians - */ - public minAngle: number = 0; - /** - * Gets or sets the maximum angle in radians - */ - public maxAngle: number = 0; - - /** - * Gets or sets the emission rate for particles (particles/sec) - */ - public emitRate: number = 1; //particles/sec - /** - * Gets or sets the life of each particle in milliseconds - */ - public particleLife: number = 2000; - /** - * Gets the opacity of each particle from 0 to 1.0 - */ - public get opacity(): number { - return this.graphics.opacity; - } - /** - * Gets the opacity of each particle from 0 to 1.0 - */ - public set opacity(opacity: number) { - this.graphics.opacity = opacity; - } - /** - * Gets or sets the fade flag which causes particles to gradually fade out over the course of their life. - */ - public fadeFlag: boolean = false; - - /** - * Gets or sets the optional focus where all particles should accelerate towards - */ - public focus: Vector = null; - /** - * Gets or sets the acceleration for focusing particles if a focus has been specified - */ - public focusAccel: number = null; - /** - * Gets or sets the optional starting size for the particles - */ - public startSize: number = null; - /** - * Gets or sets the optional ending size for the particles - */ - public endSize: number = null; - - /** - * Gets or sets the minimum size of all particles - */ - public minSize: number = 5; - /** - * Gets or sets the maximum size of all particles - */ - public maxSize: number = 5; - - /** - * Gets or sets the beginning color of all particles - */ - public beginColor: Color = Color.White; - /** - * Gets or sets the ending color of all particles - */ - public endColor: Color = Color.White; - - private _sprite: Graphic = null; - /** - * Gets or sets the sprite that a particle should use - */ - public get particleSprite(): Graphic { - return this._sprite; - } - - public set particleSprite(val: Graphic) { - if (val) { - this._sprite = val; - } - } - - /** - * Gets or sets the emitter type for the particle emitter - */ - public emitterType: EmitterType = EmitterType.Rectangle; - - /** - * Gets or sets the emitter radius, only takes effect when the [[emitterType]] is [[EmitterType.Circle]] - */ - public radius: number = 0; - - /** - * Gets or sets the particle rotational speed velocity - */ - public particleRotationalVelocity: number = 0; - - /** - * Indicates whether particles should start with a random rotation - */ - public randomRotation: boolean = false; - - /** - * Gets or sets the emitted particle transform style, [[ParticleTransform.Global]] is the default and emits particles as if - * they were world space objects, useful for most effects. - * - * If set to [[ParticleTransform.Local]] particles are children of the emitter and move relative to the emitter - * as they would in a parent/child actor relationship. - */ - public particleTransform: ParticleTransform = ParticleTransform.Global; - - /** - * @param config particle emitter options bag - */ - constructor(config: ParticleEmitterArgs) { - super({ width: config.width ?? 0, height: config.height ?? 0 }); - - const { - x, - y, - z, - pos, - isEmitting, - minVel, - maxVel, - acceleration, - minAngle, - maxAngle, - emitRate, - particleLife, - opacity, - fadeFlag, - focus, - focusAccel, - startSize, - endSize, - minSize, - maxSize, - beginColor, - endColor, - particleSprite, - emitterType, - radius, - particleRotationalVelocity, - particleTransform, - randomRotation, - random - } = { ...config }; - - this.pos = pos ?? vec(x ?? 0, y ?? 0); - this.z = z ?? 0; - this.isEmitting = isEmitting ?? this.isEmitting; - this.minVel = minVel ?? this.minVel; - this.maxVel = maxVel ?? this.maxVel; - this.acceleration = acceleration ?? this.acceleration; - this.minAngle = minAngle ?? this.minAngle; - this.maxAngle = maxAngle ?? this.maxAngle; - this.emitRate = emitRate ?? this.emitRate; - this.particleLife = particleLife ?? this.particleLife; - this.opacity = opacity ?? this.opacity; - this.fadeFlag = fadeFlag ?? this.fadeFlag; - this.focus = focus ?? this.focus; - this.focusAccel = focusAccel ?? this.focusAccel; - this.startSize = startSize ?? this.startSize; - this.endSize = endSize ?? this.endSize; - this.minSize = minSize ?? this.minSize; - this.maxSize = maxSize ?? this.maxSize; - this.beginColor = beginColor ?? this.beginColor; - this.endColor = endColor ?? this.endColor; - this.particleSprite = particleSprite ?? this.particleSprite; - this.emitterType = emitterType ?? this.emitterType; - this.radius = radius ?? this.radius; - this.particleRotationalVelocity = particleRotationalVelocity ?? this.particleRotationalVelocity; - this.randomRotation = randomRotation ?? this.randomRotation; - this.particleTransform = particleTransform ?? this.particleTransform; - - this.body.collisionType = CollisionType.PreventCollision; - - this.random = random ?? new Random(); - } - - public removeParticle(particle: Particle) { - this.deadParticles.push(particle); - } - - /** - * Causes the emitter to emit particles - * @param particleCount Number of particles to emit right now - */ - public emitParticles(particleCount: number) { - for (let i = 0; i < particleCount; i++) { - const p = this._createParticle(); - this.particles.push(p); - if (this?.scene?.world) { - if (this.particleTransform === ParticleTransform.Global) { - this.scene.world.add(p); - } else { - this.addChild(p); - } - } - } - } - - public clearParticles() { - this.particles.length = 0; - } - - // Creates a new particle given the constraints of the emitter - private _createParticle(): Particle { - // todo implement emitter constraints; - let ranX = 0; - let ranY = 0; - - const angle = randomInRange(this.minAngle, this.maxAngle, this.random); - const vel = randomInRange(this.minVel, this.maxVel, this.random); - const size = this.startSize || randomInRange(this.minSize, this.maxSize, this.random); - const dx = vel * Math.cos(angle); - const dy = vel * Math.sin(angle); - - if (this.emitterType === EmitterType.Rectangle) { - ranX = randomInRange(0, this.width, this.random); - ranY = randomInRange(0, this.height, this.random); - } else if (this.emitterType === EmitterType.Circle) { - const radius = randomInRange(0, this.radius, this.random); - ranX = radius * Math.cos(angle); - ranY = radius * Math.sin(angle); - } - - const p = new Particle( - this, - this.particleLife, - this.opacity, - this.beginColor, - this.endColor, - new Vector(ranX, ranY), - new Vector(dx, dy), - this.acceleration, - this.startSize, - this.endSize, - this.particleSprite - ); - p.fadeFlag = this.fadeFlag; - p.particleSize = size; - p.particleRotationalVelocity = this.particleRotationalVelocity; - if (this.randomRotation) { - p.currentRotation = randomInRange(0, Math.PI * 2, this.random); - } - if (this.focus) { - p.focus = this.focus.add(new Vector(this.pos.x, this.pos.y)); - p.focusAccel = this.focusAccel; - } - return p; - } - - public update(engine: Engine, delta: number) { - super.update(engine, delta); - - if (this.isEmitting) { - this._particlesToEmit += this.emitRate * (delta / 1000); - if (this._particlesToEmit > 1.0) { - this.emitParticles(Math.floor(this._particlesToEmit)); - this._particlesToEmit = this._particlesToEmit - Math.floor(this._particlesToEmit); - } - } - - // deferred removal - for (let i = 0; i < this.deadParticles.length; i++) { - Util.removeItemFromArray(this.deadParticles[i], this.particles); - if (this?.scene?.world) { - this.scene.world.remove(this.deadParticles[i], false); - } - } - this.deadParticles.length = 0; - } -} diff --git a/src/engine/Particles.ts b/src/engine/Particles.ts deleted file mode 100644 index 7cb05cdea..000000000 --- a/src/engine/Particles.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { Engine } from './Engine'; -import { Color } from './Color'; -import { Vector, vec } from './Math/vector'; -import { Configurable } from './Configurable'; -import { Random } from './Math/Random'; -import { TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; -import { GraphicsComponent } from './Graphics/GraphicsComponent'; -import { Entity } from './EntityComponentSystem/Entity'; -import { BoundingBox } from './Collision/BoundingBox'; -import { clamp } from './Math/util'; -import { Graphic } from './Graphics'; -import { EmitterType } from './EmitterType'; -import type { ParticleEmitter } from './ParticleEmitter'; - -const isEmitterConfig = (emitterOrConfig: ParticleEmitter | ParticleArgs): emitterOrConfig is ParticleArgs => { - return emitterOrConfig && !emitterOrConfig.constructor; -}; - -/** - * @hidden - */ -export class ParticleImpl extends Entity { - public position: Vector = new Vector(0, 0); - public velocity: Vector = new Vector(0, 0); - public acceleration: Vector = new Vector(0, 0); - public particleRotationalVelocity: number = 0; - public currentRotation: number = 0; - - public focus: Vector = null; - public focusAccel: number = 0; - public opacity: number = 1; - public beginColor: Color = Color.White; - public endColor: Color = Color.White; - - // Life is counted in ms - public life: number = 300; - public fadeFlag: boolean = false; - - // Color transitions - private _rRate: number = 1; - private _gRate: number = 1; - private _bRate: number = 1; - private _aRate: number = 0; - private _currentColor: Color = Color.White; - - public emitter: ParticleEmitter = null; - public particleSize: number = 5; - public particleSprite: Graphic = null; - - public startSize: number; - public endSize: number; - public sizeRate: number = 0; - public elapsedMultiplier: number = 0; - - public visible = true; - public isOffscreen = false; - - public transform: TransformComponent; - public graphics: GraphicsComponent; - - constructor( - emitterOrConfig: ParticleEmitter | ParticleArgs, - life?: number, - opacity?: number, - beginColor?: Color, - endColor?: Color, - position?: Vector, - velocity?: Vector, - acceleration?: Vector, - startSize?: number, - endSize?: number, - particleSprite?: Graphic - ) { - super(); - let emitter = emitterOrConfig; - if (isEmitterConfig(emitterOrConfig)) { - const config = emitterOrConfig; - emitter = config.emitter; - life = config.life; - opacity = config.opacity; - endColor = config.endColor; - beginColor = config.beginColor; - position = config.position; - velocity = config.velocity; - acceleration = config.acceleration; - startSize = config.startSize; - endSize = config.endSize; - particleSprite = config.particleSprite; - } - this.emitter = emitter; - this.life = life || this.life; - this.opacity = opacity || this.opacity; - this.endColor = endColor || this.endColor.clone(); - this.beginColor = beginColor || this.beginColor.clone(); - this._currentColor = this.beginColor.clone(); - this.particleSprite = particleSprite; - - if (this.emitter.particleTransform === ParticleTransform.Global) { - const globalPos = this.emitter.transform.globalPos; - this.position = (position || this.position).add(globalPos); - this.velocity = (velocity || this.velocity).rotate(this.emitter.transform.globalRotation); - } else { - this.velocity = velocity || this.velocity; - this.position = position || this.position; - } - this.acceleration = acceleration || this.acceleration; - this._rRate = (this.endColor.r - this.beginColor.r) / this.life; - this._gRate = (this.endColor.g - this.beginColor.g) / this.life; - this._bRate = (this.endColor.b - this.beginColor.b) / this.life; - this._aRate = this.opacity / this.life; - - this.startSize = startSize || 0; - this.endSize = endSize || 0; - - if (this.endSize > 0 && this.startSize > 0) { - this.sizeRate = (this.endSize - this.startSize) / this.life; - this.particleSize = this.startSize; - } - - this.addComponent((this.transform = new TransformComponent())); - this.addComponent((this.graphics = new GraphicsComponent())); - - this.transform.pos = this.position; - this.transform.rotation = this.currentRotation; - this.transform.scale = vec(1, 1); // TODO wut - this.transform.z = this.emitter.z; - if (this.particleSprite) { - this.graphics.opacity = this.opacity; - this.graphics.use(this.particleSprite); - } else { - this.graphics.localBounds = BoundingBox.fromDimension(this.particleSize, this.particleSize, Vector.Half); - this.graphics.onPostDraw = (ctx) => { - ctx.save(); - this.graphics.opacity = this.opacity; - const tmpColor = this._currentColor.clone(); - tmpColor.a = 1; - ctx.debug.drawPoint(vec(0, 0), { color: tmpColor, size: this.particleSize }); - ctx.restore(); - }; - } - } - - public kill() { - this.emitter.removeParticle(this); - } - - public update(engine: Engine, delta: number) { - this.life = this.life - delta; - this.elapsedMultiplier = this.elapsedMultiplier + delta; - - if (this.life < 0) { - this.kill(); - } - - if (this.fadeFlag) { - this.opacity = clamp(this._aRate * this.life, 0.0001, 1); - } - - if (this.startSize > 0 && this.endSize > 0) { - this.particleSize = clamp( - this.sizeRate * delta + this.particleSize, - Math.min(this.startSize, this.endSize), - Math.max(this.startSize, this.endSize) - ); - } - - this._currentColor.r = clamp(this._currentColor.r + this._rRate * delta, 0, 255); - this._currentColor.g = clamp(this._currentColor.g + this._gRate * delta, 0, 255); - this._currentColor.b = clamp(this._currentColor.b + this._bRate * delta, 0, 255); - this._currentColor.a = clamp(this.opacity, 0.0001, 1); - - if (this.focus) { - const accel = this.focus - .sub(this.position) - .normalize() - .scale(this.focusAccel) - .scale(delta / 1000); - this.velocity = this.velocity.add(accel); - } else { - this.velocity = this.velocity.add(this.acceleration.scale(delta / 1000)); - } - this.position = this.position.add(this.velocity.scale(delta / 1000)); - - if (this.particleRotationalVelocity) { - this.currentRotation = (this.currentRotation + (this.particleRotationalVelocity * delta) / 1000) % (2 * Math.PI); - } - - this.transform.pos = this.position; - this.transform.rotation = this.currentRotation; - this.transform.scale = vec(1, 1); // todo wut - this.graphics.opacity = this.opacity; - } -} - -export interface ParticleArgs extends Partial { - emitter: ParticleEmitter; - position?: Vector; - velocity?: Vector; - acceleration?: Vector; - particleRotationalVelocity?: number; - currentRotation?: number; - particleSize?: number; - particleSprite?: Graphic; -} - -/** - * Particle is used in a [[ParticleEmitter]] - */ -export class Particle extends Configurable(ParticleImpl) { - constructor(config: ParticleArgs); - constructor( - emitter: ParticleEmitter, - life?: number, - opacity?: number, - beginColor?: Color, - endColor?: Color, - position?: Vector, - velocity?: Vector, - acceleration?: Vector, - startSize?: number, - endSize?: number, - particleSprite?: Graphic - ); - constructor( - emitterOrConfig: ParticleEmitter | ParticleArgs, - life?: number, - opacity?: number, - beginColor?: Color, - endColor?: Color, - position?: Vector, - velocity?: Vector, - acceleration?: Vector, - startSize?: number, - endSize?: number, - particleSprite?: Graphic - ) { - super(emitterOrConfig, life, opacity, beginColor, endColor, position, velocity, acceleration, startSize, endSize, particleSprite); - } -} - -export enum ParticleTransform { - /** - * [[ParticleTransform.Global]] is the default and emits particles as if - * they were world space objects, useful for most effects. - */ - Global = 'global', - /** - * [[ParticleTransform.Local]] particles are children of the emitter and move relative to the emitter - * as they would in a parent/child actor relationship. - */ - Local = 'local' -} - -export interface ParticleEmitterArgs { - x?: number; - y?: number; - z?: number; - pos?: Vector; - width?: number; - height?: number; - isEmitting?: boolean; - minVel?: number; - maxVel?: number; - acceleration?: Vector; - minAngle?: number; - maxAngle?: number; - emitRate?: number; - particleLife?: number; - /** - * Optionally set the emitted particle transform style, [[ParticleTransform.Global]] is the default and emits particles as if - * they were world space objects, useful for most effects. - * - * If set to [[ParticleTransform.Local]] particles are children of the emitter and move relative to the emitter - * as they would in a parent/child actor relationship. - */ - particleTransform?: ParticleTransform; - opacity?: number; - fadeFlag?: boolean; - focus?: Vector; - focusAccel?: number; - startSize?: number; - endSize?: number; - minSize?: number; - maxSize?: number; - beginColor?: Color; - endColor?: Color; - particleSprite?: Graphic; - emitterType?: EmitterType; - radius?: number; - particleRotationalVelocity?: number; - randomRotation?: boolean; - random?: Random; -} diff --git a/src/engine/Particles/ParticleEmitter.ts b/src/engine/Particles/ParticleEmitter.ts new file mode 100644 index 000000000..8f9f08343 --- /dev/null +++ b/src/engine/Particles/ParticleEmitter.ts @@ -0,0 +1,184 @@ +import { Engine } from '../Engine'; +import { Actor } from '../Actor'; +import { vec } from '../Math/vector'; +import { Random } from '../Math/Random'; +import { CollisionType } from '../Collision/CollisionType'; +import { randomInRange } from '../Math/util'; +import { EmitterType } from '../EmitterType'; +import { Particle, ParticleTransform, ParticleEmitterArgs, ParticleConfig } from './Particles'; +import { RentalPool } from '../Util/RentalPool'; + +/** + * Used internally by Excalibur to manage all particles in the engine + */ +export const ParticlePool = new RentalPool( + () => new Particle({}), + (p) => p, + 2000 +); + +/** + * Using a particle emitter is a great way to create interesting effects + * in your game, like smoke, fire, water, explosions, etc. `ParticleEmitter` + * extend [[Actor]] allowing you to use all of the features that come with. + */ +export class ParticleEmitter extends Actor { + private _particlesToEmit: number = 0; + + public numParticles: number = 0; + + /** + * Random number generator + */ + public random: Random; + + /** + * Gets or sets the isEmitting flag + */ + public isEmitting: boolean = true; + + /** + * Gets or sets the backing deadParticle collection + */ + public deadParticles: Particle[] = []; + + /** + * Gets or sets the emission rate for particles (particles/sec) + */ + public emitRate: number = 1; //particles/sec + + /** + * Gets or sets the emitter type for the particle emitter + */ + public emitterType: EmitterType = EmitterType.Rectangle; + + /** + * Gets or sets the emitter radius, only takes effect when the [[emitterType]] is [[EmitterType.Circle]] + */ + public radius: number = 0; + + public particle: ParticleConfig = { + /** + * Gets or sets the life of each particle in milliseconds + */ + life: 2000, + transform: ParticleTransform.Global, + graphic: undefined, + opacity: 1, + angularVelocity: 0, + focus: undefined, + focusAccel: undefined, + randomRotation: false + }; + + /** + * @param config particle emitter options bag + */ + constructor(config: ParticleEmitterArgs) { + super({ width: config.width ?? 0, height: config.height ?? 0 }); + + const { particle, x, y, z, pos, isEmitting, emitRate, emitterType, radius, random } = { ...config }; + + this.particle = { ...this.particle, ...particle }; + + this.pos = pos ?? vec(x ?? 0, y ?? 0); + this.z = z ?? 0; + this.isEmitting = isEmitting ?? this.isEmitting; + this.emitRate = emitRate ?? this.emitRate; + this.emitterType = emitterType ?? this.emitterType; + this.radius = radius ?? this.radius; + + this.body.collisionType = CollisionType.PreventCollision; + + this.random = random ?? new Random(); + } + + public removeParticle(particle: Particle) { + this.deadParticles.push(particle); + } + + /** + * Causes the emitter to emit particles + * @param particleCount Number of particles to emit right now + */ + public emitParticles(particleCount: number) { + for (let i = 0; i < particleCount; i++) { + const p = this._createParticle(); + if (this?.scene?.world) { + if (this.particle.transform === ParticleTransform.Global) { + this.scene.world.add(p); + } else { + this.addChild(p); + } + } + } + } + + // Creates a new particle given the constraints of the emitter + private _createParticle(): Particle { + let ranX = 0; + let ranY = 0; + + const angle = randomInRange(this.particle.minAngle || 0, this.particle.maxAngle || Math.PI * 2, this.random); + const vel = randomInRange(this.particle.minVel || 0, this.particle.maxVel || 0, this.random); + const size = this.particle.startSize || randomInRange(this.particle.minSize || 5, this.particle.maxSize || 5, this.random); + const dx = vel * Math.cos(angle); + const dy = vel * Math.sin(angle); + + if (this.emitterType === EmitterType.Rectangle) { + ranX = randomInRange(0, this.width, this.random); + ranY = randomInRange(0, this.height, this.random); + } else if (this.emitterType === EmitterType.Circle) { + const radius = randomInRange(0, this.radius, this.random); + ranX = radius * Math.cos(angle); + ranY = radius * Math.sin(angle); + } + + const p = ParticlePool.rent(); + p.configure({ + life: this.particle.life, + opacity: this.particle.opacity, + beginColor: this.particle.beginColor, + endColor: this.particle.endColor, + pos: vec(ranX, ranY), + vel: vec(dx, dy), + acc: this.particle.acc, + angularVelocity: this.particle.angularVelocity, + startSize: this.particle.startSize, + endSize: this.particle.endSize, + size: size, + graphic: this.particle.graphic, + fade: this.particle.fade + }); + p.registerEmitter(this); + if (this.particle.randomRotation) { + p.transform.rotation = randomInRange(0, Math.PI * 2, this.random); + } + if (this.particle.focus) { + p.focus = this.particle.focus.add(vec(this.pos.x, this.pos.y)); + p.focusAccel = this.particle.focusAccel; + } + return p; + } + + public update(engine: Engine, delta: number) { + super.update(engine, delta); + + if (this.isEmitting) { + this._particlesToEmit += this.emitRate * (delta / 1000); + if (this._particlesToEmit > 1.0) { + this.emitParticles(Math.floor(this._particlesToEmit)); + this._particlesToEmit = this._particlesToEmit - Math.floor(this._particlesToEmit); + } + } + + // deferred removal + for (let i = 0; i < this.deadParticles.length; i++) { + if (this?.scene?.world) { + this.scene.world.remove(this.deadParticles[i], false); + ParticlePool.return(this.deadParticles[i]); + } + } + this.deadParticles.length = 0; + } +} diff --git a/src/engine/Particles/Particles.ts b/src/engine/Particles/Particles.ts new file mode 100644 index 000000000..357179e72 --- /dev/null +++ b/src/engine/Particles/Particles.ts @@ -0,0 +1,300 @@ +import { Engine } from '../Engine'; +import { Color } from '../Color'; +import { Vector, vec } from '../Math/vector'; +import { Random } from '../Math/Random'; +import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; +import { GraphicsComponent } from '../Graphics/GraphicsComponent'; +import { Entity } from '../EntityComponentSystem/Entity'; +import { BoundingBox } from '../Collision/BoundingBox'; +import { clamp } from '../Math/util'; +import { Graphic } from '../Graphics'; +import { EmitterType } from '../EmitterType'; +import { MotionComponent } from '../EntityComponentSystem'; +import { EulerIntegrator } from '../Collision/Integrator'; +import type { ParticleEmitter } from './ParticleEmitter'; + +/** +/** + * Particle is used in a [[ParticleEmitter]] + */ +export class Particle extends Entity { + public static DefaultConfig: ParticleConfig = { + beginColor: Color.White, + endColor: Color.White, + life: 300, + fade: false, + size: 5, + graphic: undefined, + startSize: undefined, + endSize: undefined + }; + + public focus?: Vector; + public focusAccel?: number; + public beginColor: Color = Color.White; + public endColor: Color = Color.White; + + // Life is counted in ms + public life: number = 300; + public fade: boolean = false; + + // Color transitions + private _rRate: number = 1; + private _gRate: number = 1; + private _bRate: number = 1; + private _aRate: number = 0; + private _currentColor: Color = Color.White; + + public size: number = 5; + public graphic?: Graphic; + + public startSize?: number; + public endSize?: number; + public sizeRate: number = 0; + + public visible = true; + public isOffscreen = false; + + public transform: TransformComponent; + public motion: MotionComponent; + public graphics: GraphicsComponent; + public particleTransform = ParticleTransform.Global; + + constructor(options: ParticleConfig) { + super(); + this.addComponent((this.transform = new TransformComponent())); + this.addComponent((this.motion = new MotionComponent())); + this.addComponent((this.graphics = new GraphicsComponent())); + this.configure(options); + } + + private _emitter?: ParticleEmitter; + registerEmitter(emitter: ParticleEmitter) { + this._emitter = emitter; + if (this.particleTransform === ParticleTransform.Global) { + const globalPos = this._emitter.transform.globalPos; + this.transform.pos = this.transform.pos.add(globalPos); + this.motion.vel = this.motion.vel.rotate(this._emitter.transform.globalRotation); + } + } + + configure(options: ParticleConfig) { + this.particleTransform = options.transform ?? this.particleTransform; + this.life = options.life ?? this.life; + this.fade = options.fade ?? this.fade; + this.size = options.size ?? this.size; + this.endColor = options.endColor ?? this.endColor.clone(); + this.beginColor = options.beginColor ?? this.beginColor.clone(); + this._currentColor = this.beginColor.clone(); + + this.graphic = options.graphic; + this.graphics.opacity = options.opacity ?? this.graphics.opacity; + this.transform.pos = options.pos ?? this.transform.pos; + this.transform.rotation = options.rotation ?? 0; + this.transform.scale = vec(1, 1); + + this.motion.vel = options.vel ?? this.motion.vel; + this.motion.angularVelocity = options.angularVelocity ?? 0; + this.motion.acc = options.acc ?? this.motion.acc; + + this._rRate = (this.endColor.r - this.beginColor.r) / this.life; + this._gRate = (this.endColor.g - this.beginColor.g) / this.life; + this._bRate = (this.endColor.b - this.beginColor.b) / this.life; + this._aRate = this.graphics.opacity / this.life; + + this.startSize = options.startSize ?? 0; + this.endSize = options.endSize ?? 0; + + if (this.endSize > 0 && this.startSize > 0) { + this.sizeRate = (this.endSize - this.startSize) / this.life; + this.size = this.startSize; + } + if (this.graphic) { + this.graphics.use(this.graphic); + this.graphics.onPostDraw = undefined; + } else { + this.graphics.localBounds = BoundingBox.fromDimension(this.size, this.size, Vector.Half); + this.graphics.onPostDraw = (ctx) => { + ctx.save(); + ctx.debug.drawPoint(vec(0, 0), { color: this._currentColor, size: this.size }); + ctx.restore(); + }; + } + } + + public kill() { + if (this._emitter) { + this._emitter.removeParticle(this); + } + } + + public update(engine: Engine, delta: number) { + this.life = this.life - delta; + + if (this.life < 0) { + this.kill(); + } + + if (this.fade) { + this.graphics.opacity = clamp(this._aRate * this.life, 0.0001, 1); + } + + if (this.startSize && this.endSize && this.startSize > 0 && this.endSize > 0) { + this.size = clamp(this.sizeRate * delta + this.size, Math.min(this.startSize, this.endSize), Math.max(this.startSize, this.endSize)); + } + + this._currentColor.r = clamp(this._currentColor.r + this._rRate * delta, 0, 255); + this._currentColor.g = clamp(this._currentColor.g + this._gRate * delta, 0, 255); + this._currentColor.b = clamp(this._currentColor.b + this._bRate * delta, 0, 255); + this._currentColor.a = this.graphics.opacity; + + let accel = this.motion.acc; + if (this.focus) { + accel = this.focus + .sub(this.transform.pos) + .normalize() + .scale(this.focusAccel || 0) + .scale(delta / 1000); + } + // Update transform and motion based on Euler linear algebra + EulerIntegrator.integrate(this.transform, this.motion, accel, delta); + } +} + +export interface ParticleConfig { + /** + * Optionally set the emitted particle transform style, [[ParticleTransform.Global]] is the default and emits particles as if + * they were world space objects, useful for most effects. + * + * If set to [[ParticleTransform.Local]] particles are children of the emitter and move relative to the emitter + * as they would in a parent/child actor relationship. + */ + transform?: ParticleTransform; + /** + * Starting position of the particle + */ + pos?: Vector; + /** + * Starting velocity of the particle + */ + vel?: Vector; + /** + * Starting acceleration of the particle + */ + acc?: Vector; + /** + * Starting angular velocity of the particle + */ + angularVelocity?: number; + /** + * Starting rotation of the particle + */ + rotation?: number; + /** + * Size of the particle in pixels + */ + size?: number; + /** + * Optionally set a graphic + */ + graphic?: Graphic; + /** + * Totally life of the particle in milliseconds + */ + life?: number; + /** + * Starting opacity of the particle + */ + opacity?: number; + /** + * Should the particle fade out to fully transparent over their life + */ + fade?: boolean; + + /** + * Ending color of the particle over its life + */ + endColor?: Color; + /** + * Beginning color of the particle over its life + */ + beginColor?: Color; + + /** + * Set the start size when you want to change particle size over their life + */ + startSize?: number; + /** + * Set the end size when you want to change particle size over their life + */ + endSize?: number; + + /** + * Smallest possible starting size of the particle + */ + minSize?: number; + /** + * Largest possible starting size of the particle + */ + maxSize?: number; + /** + * Minimum magnitude of the particle starting vel + */ + minVel?: number; // TODO Change to speed! + /** + * Maximum magnitude of the particle starting vel + */ + maxVel?: number; + /** + * Minimum angle to use for the particles starting rotation + */ + minAngle?: number; + /** + * Maximum angle to use for the particles starting rotation + */ + maxAngle?: number; + + /** + * Gets or sets the optional focus where all particles should accelerate towards + */ + focus?: Vector; + /** + * Gets or sets the optional acceleration for focusing particles if a focus has been specified + */ + focusAccel?: number; + + /** + * Indicates whether particles should start with a random rotation + */ + randomRotation?: boolean; +} + +export enum ParticleTransform { + /** + * [[ParticleTransform.Global]] is the default and emits particles as if + * they were world space objects, useful for most effects. + */ + Global = 'global', + /** + * [[ParticleTransform.Local]] particles are children of the emitter and move relative to the emitter + * as they would in a parent/child actor relationship. + */ + Local = 'local' +} + +export interface ParticleEmitterArgs { + particle?: ParticleConfig; + x?: number; + y?: number; + z?: number; + pos?: Vector; + width?: number; + height?: number; + isEmitting?: boolean; + emitRate?: number; + focus?: Vector; + focusAccel?: number; + emitterType?: EmitterType; + radius?: number; + random?: Random; +} diff --git a/src/engine/Particles/tsconfig.json b/src/engine/Particles/tsconfig.json new file mode 100644 index 000000000..20d5e2e03 --- /dev/null +++ b/src/engine/Particles/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "strict": true + } +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 234d5f796..81f2f8c89 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -24,8 +24,8 @@ export * from './Events'; export * from './Label'; export { FontStyle, FontUnit, TextAlign, BaseAlign } from './Graphics/FontCommon'; export * from './EmitterType'; -export { Particle, ParticleTransform, ParticleArgs, ParticleEmitterArgs } from './Particles'; -export * from './ParticleEmitter'; +export { Particle, ParticleTransform, ParticleConfig as ParticleArgs, ParticleEmitterArgs } from './Particles/Particles'; +export * from './Particles/ParticleEmitter'; export * from './Collision/Physics'; export * from './Scene'; diff --git a/src/spec/ParticleSpec.ts b/src/spec/ParticleSpec.ts index 4818c5e80..90098bb72 100644 --- a/src/spec/ParticleSpec.ts +++ b/src/spec/ParticleSpec.ts @@ -34,28 +34,28 @@ describe('A particle', () => { width: 20, height: 30, isEmitting: true, - minVel: 40, - maxVel: 50, - acceleration: ex.Vector.Zero.clone(), - minAngle: 0, - maxAngle: Math.PI / 2, + particle: { + minVel: 40, + maxVel: 50, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4, + opacity: 0.5, + fade: false, + startSize: 1, + endSize: 10, + minSize: 1, + maxSize: 3, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3, + randomRotation: false + }, emitRate: 3, - particleLife: 4, - opacity: 0.5, - fadeFlag: false, - focus: null, - focusAccel: null, - startSize: 1, - endSize: 10, - minSize: 1, - maxSize: 3, - beginColor: ex.Color.Red.clone(), - endColor: ex.Color.Blue.clone(), - particleSprite: null, emitterType: ex.EmitterType.Circle, radius: 20, - particleRotationalVelocity: 3, - randomRotation: false, random: new ex.Random(1337) }); @@ -64,28 +64,28 @@ describe('A particle', () => { expect(emitter.width).toBe(20); expect(emitter.height).toBe(30); expect(emitter.isEmitting).toBe(true); - expect(emitter.minVel).toBe(40); - expect(emitter.maxVel).toBe(50); - expect(emitter.acceleration.toString()).toBe(ex.Vector.Zero.clone().toString()); - expect(emitter.minAngle).toBe(0); - expect(emitter.maxAngle).toBe(Math.PI / 2); + expect(emitter.particle.minVel).toBe(40); + expect(emitter.particle.maxVel).toBe(50); + expect(emitter.acc.toString()).toBe(ex.Vector.Zero.clone().toString()); + expect(emitter.particle.minAngle).toBe(0); + expect(emitter.particle.maxAngle).toBe(Math.PI / 2); expect(emitter.emitRate).toBe(3); - expect(emitter.particleLife).toBe(4); - expect(emitter.opacity).toBe(0.5); - expect(emitter.fadeFlag).toBe(false); - expect(emitter.focus).toBe(null); - expect(emitter.focusAccel).toBe(null); - expect(emitter.startSize).toBe(1); - expect(emitter.endSize).toBe(10); - expect(emitter.minSize).toBe(1); - expect(emitter.maxSize).toBe(3); - expect(emitter.beginColor.toString()).toBe(ex.Color.Red.clone().toString()); - expect(emitter.endColor.toString()).toBe(ex.Color.Blue.clone().toString()); - expect(emitter.particleSprite).toBe(null); + expect(emitter.particle.life).toBe(4); + expect(emitter.particle.opacity).toBe(0.5); + expect(emitter.particle.fade).toBe(false); + expect(emitter.particle.focus).toBe(undefined); + expect(emitter.particle.focusAccel).toBe(undefined); + expect(emitter.particle.startSize).toBe(1); + expect(emitter.particle.endSize).toBe(10); + expect(emitter.particle.minSize).toBe(1); + expect(emitter.particle.maxSize).toBe(3); + expect(emitter.particle.beginColor.toString()).toBe(ex.Color.Red.clone().toString()); + expect(emitter.particle.endColor.toString()).toBe(ex.Color.Blue.clone().toString()); + expect(emitter.particle.graphic).toBe(null); expect(emitter.emitterType).toBe(ex.EmitterType.Circle); expect(emitter.radius).toBe(20); - expect(emitter.particleRotationalVelocity).toBe(3); - expect(emitter.randomRotation).toBe(false); + expect(emitter.particle.angularVelocity).toBe(3); + expect(emitter.particle.randomRotation).toBe(false); expect(emitter.random.seed).toBe(1337); }); @@ -95,25 +95,27 @@ describe('A particle', () => { width: 20, height: 30, isEmitting: true, - minVel: 100, - maxVel: 200, - acceleration: ex.Vector.Zero.clone(), - minAngle: 0, - maxAngle: Math.PI / 2, emitRate: 5, - particleLife: 4000, - opacity: 0.5, - fadeFlag: false, + particle: { + minVel: 100, + maxVel: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + opacity: 0.5, + fade: false, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3 + }, focus: null, focusAccel: null, - startSize: 30, - endSize: 40, - beginColor: ex.Color.Red.clone(), - endColor: ex.Color.Blue.clone(), - particleSprite: null, emitterType: ex.EmitterType.Circle, radius: 20, - particleRotationalVelocity: 3, randomRotation: false, random: new ex.Random(1337) }); @@ -135,25 +137,27 @@ describe('A particle', () => { width: 20, height: 30, isEmitting: true, - minVel: 100, - maxVel: 200, - acceleration: ex.Vector.Zero.clone(), - minAngle: 0, - maxAngle: Math.PI / 2, emitRate: 5, - particleLife: 4000, - opacity: 0.5, - fadeFlag: false, + particle: { + minVel: 100, + maxVel: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + opacity: 0.5, + fade: false, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3 + }, focus: null, focusAccel: null, - startSize: 30, - endSize: 40, - beginColor: ex.Color.Red.clone(), - endColor: ex.Color.Blue.clone(), - particleSprite: null, emitterType: ex.EmitterType.Circle, radius: 20, - particleRotationalVelocity: 3, randomRotation: false, random: new ex.Random(1337) }); @@ -179,30 +183,32 @@ describe('A particle', () => { it('can set the particle transform to local making particles children of the emitter', () => { const emitter = new ex.ParticleEmitter({ - particleTransform: ex.ParticleTransform.Local, + particle: { + transform: ex.ParticleTransform.Local, + minVel: 100, + maxVel: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + fade: false, + opacity: 0.5, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3 + }, pos: new ex.Vector(0, 0), width: 20, height: 30, isEmitting: true, - minVel: 100, - maxVel: 200, - acceleration: ex.Vector.Zero.clone(), - minAngle: 0, - maxAngle: Math.PI / 2, emitRate: 5, - particleLife: 4000, - opacity: 0.5, - fadeFlag: false, focus: null, focusAccel: null, - startSize: 30, - endSize: 40, - beginColor: ex.Color.Red.clone(), - endColor: ex.Color.Blue.clone(), - particleSprite: null, emitterType: ex.EmitterType.Circle, radius: 20, - particleRotationalVelocity: 3, randomRotation: false, random: new ex.Random(1337) }); @@ -214,30 +220,32 @@ describe('A particle', () => { it('can set the particle transform to global adding particles directly to the scene', () => { const emitter = new ex.ParticleEmitter({ - particleTransform: ex.ParticleTransform.Global, + particle: { + transform: ex.ParticleTransform.Global, + minVel: 100, + maxVel: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + opacity: 0.5, + fade: false, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3 + }, pos: new ex.Vector(0, 0), width: 20, height: 30, isEmitting: true, - minVel: 100, - maxVel: 200, - acceleration: ex.Vector.Zero.clone(), - minAngle: 0, - maxAngle: Math.PI / 2, emitRate: 5, - particleLife: 4000, - opacity: 0.5, - fadeFlag: false, focus: null, focusAccel: null, - startSize: 30, - endSize: 40, - beginColor: ex.Color.Red.clone(), - endColor: ex.Color.Blue.clone(), - particleSprite: null, emitterType: ex.EmitterType.Circle, radius: 20, - particleRotationalVelocity: 3, randomRotation: false, random: new ex.Random(1337) }); diff --git a/src/spec/images/ParticleSpec/Particles.png b/src/spec/images/ParticleSpec/Particles.png index eb77885b9aec0c457adb3b432bc48210304e9454..be470677f4b95e355fec7f7592154f1a77b6a590 100644 GIT binary patch literal 13384 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 literal 11580 zcmeHtS6EYBw{?P)009CLqzFkUihy*aNH5~60+9~VtI|bKxYG9!$I!s<9WX}Vi6GwN2Yx_rnHuUsioTqjgFw)bzw~v?LmU=IzY|3UgWFg1QYvir z&M7?fX@A0Y?EZ^YF7(C2yP_8nTHiwB3;#~Muvc%rpvV`oRH#7BNpO2WEs}mXMmPI= z?v(cq_sT|M=-27yJ_i|0Kfy4i=0H({JOpE_P0?#D@rb{A}7H zwn{~DUlm$QW9a(i%g32$-DaU5VI}lw`;>5fihF3p@lV^D!Xa7dAeL)uL62S3I{o4e z2F8OT+aAd!=DF}_`Ng&q2bwR8eG>5*@sI7WDotu=m9O`q`*2WyekBxmw~>LB zS6g~}CVUPYsInr|VJA)6rLu0&!H^^)frcsz2~2s&bko+p56@aWQ?Y2YostD79aAc) z%Z2a7-@=+V=$I`y|CE{-Kj^XeB{*1cV#>MD`q;&gcl5+i^?rOpQKG0d;*wCJcuT2J zvQ20o#wJ*0Ah+XTOF0ZP)-GS4AdZ>FS^phgnq=b2X{rBZzVK%&upDhVMp6uVG#T7v z<7Za&AM`|8I;A}~feDJEX>wjAxVbNXvqR0@@io7|`((peYAVzE@8ME$9lQ|Nzm_E7 z1X;=aQ|tm(>CENHVYP@gbOb`8x|!aBO_!
eOouS-Q&TWKJf$8oL6;kt|GwKn zGwL2oo`tuE$F zKv=QJ>`cyGyt6Ig0c*rm*asH762L^PfBn-Uz(hthBxa&2t^Kl`q@#j5cCI5om|2LP zTPVUYZ@6$`(y&yjILF~(%xglOe_=(eKIjU zp~KS%wj)2;1h?&c+4{J?cEAXz`zm*!$2zLUoQqAsff=+JqF3antXFK^_3GTC!D4zw zf=z{I+sl%5PyV+lE9Gy(F!S;X`2p^+2^J*Hy!&4S&K_hn6oHd@nb)_=#j ztMHg@U5F6)w2F?#^6iYE19O)wWiCS*hUgd(V;sO?M$?@>O`4d>lOvv&lhMJ=Gd9J} zGc|ZxN``4{t4@e|)=*+z))?X?E*56>@xz*8D2x+sb!DZ@$uutQz=`IQE~ywi#>QNa zjE@kyDuMEe5Q|0_Z(owVX$An&m#dACz?3`v-lpKdI+~@NTdw){W{$^sSzRDrmgqyH1KUYn#USts-J10pVM4QIB_GBBkilFwB zl8@eK;StS`>&-8md=lK|?;&Fp!-|xRwwq1fDnkM>Z^oVd@TT(2N%T4_y33$;((#PvB_*N>)3Nw)$wqJg_(SN@Y9SVE1e>cn+wLO?gDwsfAs;+p zNc4-3b?_-E9t>5lY(9WG;^k=Qt6OX|v$6F(EvtnZ*nx}`+$kRlatK$i_iol7n+sx= zq(|&(&wG0#@l1NfFtmE03J>j5>Gpx)XVTd?H={$}d1i@hZ4&@pOvGR0{y`T7Iy}xZ z{aMkW^vq3Vzf{@gE@r`som%$Fm-8#@xGW;Sd|+lu#*RdbVYmiy6K-wiUKE(6BMJ;> zBq{vnXUSgc`Gn<@6bv2_%cxg){gNlc2EXog<6`KWx~pe$Zf4d$xScxf@ta_10i0gS z3EAkA{9 zR*7mIM`_np?U?%jAfAfCscQg0Hg3v5_Nx6RCEG!w49gL%0AHWP!#yfKPMG642B0~Z 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 Jr0W#>{{ZZgjn)7F diff --git a/src/spec/images/ParticleSpec/parented.png b/src/spec/images/ParticleSpec/parented.png index 380e38c7e7b018077b7a6b58e857fdcc4c95136c..0e1f08fe9e4129da0329716bc221492e11096e71 100644 GIT binary patch literal 19441 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 literal 15186 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 HMacgFw;IXi diff --git a/src/stories/ParticleEmitter.stories.ts b/src/stories/ParticleEmitter.stories.ts index 3b4a42de3..d977bcf30 100644 --- a/src/stories/ParticleEmitter.stories.ts +++ b/src/stories/ParticleEmitter.stories.ts @@ -22,8 +22,8 @@ export const Main: StoryObj = { isEmitting, emitRate, opacity, - fadeFlag, - particleLife, + fade, + life, minSize, maxSize, startSize, @@ -39,29 +39,31 @@ export const Main: StoryObj = { // Particle Emitter const emitter = new ParticleEmitter({ - x: game.currentScene.camera.x, - y: game.currentScene.camera.y, + x: game.halfDrawWidth, + y: game.halfDrawHeight, width, height, emitterType, radius, - minVel, - maxVel, - minAngle, - maxAngle, isEmitting, emitRate, - opacity, - fadeFlag, - particleLife, - minSize, - maxSize, - startSize, - endSize, - acceleration: new Vector(accelX, accelY), - beginColor: Color.fromRGBString(beginColor), - endColor: Color.fromRGBString(endColor), - focusAccel: 800 + focusAccel: 800, + particle: { + minVel, + maxVel, + minAngle, + maxAngle, + opacity, + fade, + life, + minSize, + maxSize, + startSize, + endSize, + acc: new Vector(accelX, accelY), + beginColor: Color.fromRGBString(beginColor), + endColor: Color.fromRGBString(endColor) + } }); game.add(emitter); @@ -104,8 +106,8 @@ export const Main: StoryObj = { isEmitting: true, emitRate: 300, opacity: 0.5, - fadeFlag: true, - particleLife: 1000, + fade: true, + life: 1000, minSize: 1, maxSize: 10, startSize: 5,