diff --git a/.github/.cspell/gamedev_dictionary.txt b/.github/.cspell/gamedev_dictionary.txt index 639095fbfc4..753253b3587 100644 --- a/.github/.cspell/gamedev_dictionary.txt +++ b/.github/.cspell/gamedev_dictionary.txt @@ -58,7 +58,6 @@ texels # plural of texel tileset # image with a collection of tiles. in games, tiles are small square sprites laid out in a grid to form the game map tilesets # plural of tileset truecolor # truecolor rendering -tweening # the process of tween uncapturederror # dumb webgpu javascript name unorm # unsigned normalized integer viewports # plural of viewport diff --git a/.github/.cspell/words_dictionary.txt b/.github/.cspell/words_dictionary.txt index 0132ba2a363..81f3c21635a 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -28,3 +28,4 @@ tappable thumbstick trackpad underutilize +untinted diff --git a/doc/flame/rendering/particles.md b/doc/flame/rendering/particles.md index 6297ff7965d..373b662b5b7 100644 --- a/doc/flame/rendering/particles.md +++ b/doc/flame/rendering/particles.md @@ -1,452 +1,303 @@ # Particles -Flame offers a basic, yet robust and extendable particle system. The core concept of this system is -the `Particle` class, which is very similar in its behavior to the `ParticleSystemComponent`. +Flame ships a data-oriented particle system built for large particle counts: +all particle state lives in preallocated typed-data buffers, and the built-in +renderers draw every particle of an emitter in a single batched canvas call. +Effects are described declaratively through reusable presets, so most +particle effects require no custom classes and no per-frame allocation. -The most basic usage of a `Particle` with `FlameGame` would look as in the following: +The system consists of three parts: -```dart -import 'package:flame/components.dart'; +- [`ParticleEmitter`](#particleemitter): a declarative, reusable description + of what to spawn and how particles behave over their lifetime. +- [`ParticleRenderer`](#renderers): how particles are drawn (batched circles, + sprites, or fully custom canvas code). +- [`ParticleEmitterComponent`](#particleemittercomponent): the + `PositionComponent` that ties the two together, simulating and rendering + the particles. -// ... +A minimal explosion looks like this: -game.add( - // Wrapping a Particle with ParticleSystemComponent - // which maps Component lifecycle hooks to Particle ones - // and embeds a trigger for removing the component. - ParticleSystemComponent( - particle: CircleParticle(), +```dart +import 'package:flame/particles.dart'; + +world.add( + ParticleEmitterComponent( + position: Vector2(200, 100), + emitter: ParticleEmitter( + bursts: [EmitterBurst(0, 200)], + lifespan: (0.4, 1.2), + speed: (50, 150), + gravity: Vector2(0, 200), + scaleOverLife: ParticleCurve(1, 0), + colorOverLife: ColorRamp([Colors.yellow, Colors.red]), + ), + renderer: CircleParticleRenderer(), ), ); ``` -When using `Particle` with a custom `Game` implementation, please ensure that both the `update` and -`render` methods are called during each game loop tick. +The component removes itself automatically once the burst has fired and the +last particle has died (see [Lifecycle](#lifecycle)). -Main approaches to implement desired particle effects: -- Composition of existing behaviors. -- Use behavior chaining (just a syntactic sugar of the first one). -- Using `ComputedParticle`. +## Ranges -Composition works in a similar fashion to those of Flutter widgets by defining the effect from top -to bottom. Chaining allows to express the same composition trees more fluently by defining behaviors -from bottom to top. Computed particles in their turn fully delegate implementation of the behavior -to your code. Any of the approaches could be used in conjunction with existing behaviors where -needed. +Emitter properties that vary from particle to particle are expressed as a +`(min, max)` record called `ParticleRange`; each particle samples a uniform +value from it when it spawns. Use the same value twice for a constant: ```dart -Random rnd = Random(); - -Vector2 randomVector2() => (Vector2.random(rnd) - Vector2.random(rnd)) * 200; - -// Composition. -// -// Defining a particle effect as a set of nested behaviors from top to bottom, -// one within another: -// -// ParticleSystemComponent -// > ComposedParticle -// > AcceleratedParticle -// > CircleParticle -game.add( - ParticleSystemComponent( - particle: Particle.generate( - count: 10, - generator: (i) => AcceleratedParticle( - acceleration: randomVector2(), - child: CircleParticle( - paint: Paint()..color = Colors.red, - ), - ), - ), - ), -); +lifespan: (0.5, 2), // between 0.5 and 2 seconds +size: (8, 8), // always exactly 8 +``` -// Chaining. -// -// Expresses the same behavior as above, but with a more fluent API. -// Only Particles with SingleChildParticle mixin can be used as chainable behaviors. -game.add( - ParticleSystemComponent( - particle: Particle.generate( - count: 10, - generator: (i) => pt.CircleParticle(paint: Paint()..color = Colors.red) - ) - ) -); -// Computed Particle. -// -// All the behaviors are defined explicitly. Offers greater flexibility -// compared to built-in behaviors. -game.add( - ParticleSystemComponent( - particle: Particle.generate( - count: 10, - generator: (i) { - Vector2 position = Vector2.zero(); - Vector2 speed = Vector2.zero(); - final acceleration = randomVector2(); - final paint = Paint()..color = Colors.red; - - return ComputedParticle( - renderer: (canvas, _) { - speed += acceleration; - position += speed; - canvas.drawCircle(Offset(position.x, position.y), 1, paint); - } - ); - } - ) - ) -); -``` +## ParticleEmitter -See more [examples of how to use built-in particles in various -combinations](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/rendering/particles_example.dart). +`ParticleEmitter` is a reusable preset that can be shared by any number of +emitter components. All distances are in the component's local units, angles +in radians, and times in seconds. -## Lifecycle +### Emission -A behavior common to all `Particle`s is that all of them accept a `lifespan` argument. This value is -used to make the `ParticleSystemComponent` remove itself once its internal `Particle` has reached -the end of its life. Time within the `Particle` itself is tracked using the Flame `Timer` class. It -can be configured with a `double`, represented in seconds (with microsecond precision) by passing -it into the corresponding `Particle` constructor. +Particles can be spawned continuously, in bursts, or both: ```dart -Particle(lifespan: .2); // will live for 200ms. -Particle(lifespan: 4); // will live for 4s. +ParticleEmitter( + rate: 120, // particles per second + bursts: [ + EmitterBurst(0, 50), // 50 particles at t = 0 + EmitterBurst(0.5, 25), // 25 more at t = 0.5s + ], + duration: 1.5, // stop emitting after 1.5s + loop: true, // and restart the timeline + maxParticles: 2048, // storage for simultaneous particles +); ``` -It is also possible to reset a `Particle`'s lifespan by using the `setLifespan` method, which also -accepts a `double` of seconds. +- `rate` emits continuously; fractional amounts accumulate correctly across + frames. +- `bursts` fire once per pass over the emission timeline. +- `duration` ends emission; when null, a `rate`-based emitter runs forever + and a burst-only emitter finishes after its last burst. +- `loop` restarts the timeline after `duration` (which must then be set). +- `maxParticles` is allocated up front; spawns beyond it are dropped until + older particles die. -```dart -final particle = Particle(lifespan: 2); -// ... after some time. -particle.setLifespan(2) // will live for another 2s. -``` +### Spawn position and velocity -During its lifetime, a `Particle` tracks the time it was alive and exposes it through the `progress` -getter, which returns a value between `0.0` and `1.0`. This value can be used in a similar fashion -as the `value` property of the `AnimationController` class in Flutter. +`shape` controls where particles appear, relative to the component's +position: ```dart -final particle = Particle(lifespan: 2.0); +shape: const PointEmitterShape(), // the default +shape: const CircleEmitterShape(50), // inside a circle +shape: const CircleEmitterShape(50, edgeOnly: true), // on its edge +shape: const RectangleEmitterShape(100, 20), // inside a rectangle +``` -game.add(ParticleSystemComponent(particle: particle)); +Custom spawn patterns are one subclass away: extend `EmitterShape` and +implement `samplePosition`. -// Will print values from 0 to 1 with step of .1: 0, 0.1, 0.2 ... 0.9, 1.0. -Timer.periodic(duration * .1, () => print(particle.progress)); -``` +The initial velocity is polar: a `speed` range plus a `direction` (radians, +0 points along the positive x-axis, `-tau / 4` points up) with a `spread` +angle centered on it. The default spread of `tau` emits in all directions. -The `lifespan` is passed down to all the descendants of a given `Particle`, if it supports any of -the nesting behaviors. +```dart +speed: (100, 200), +direction: -tau / 4, // up +spread: tau / 8, // in a 45 degree cone +``` -## Built-in particles +### Forces and rotation -Flame ships with a few built-in `Particle` behaviors: +```dart +gravity: Vector2(0, 300), // constant acceleration +drag: 2, // velocity damping, 0 = none +rotation: (0, tau), // initial rotation +spin: (-5, 5), // radians per second +rotateToVelocity: true, // or: always face the direction of travel +``` -- The `TranslatedParticle` translates its `child` by given `Vector2` -- The `MovingParticle` moves its `child` between two predefined `Vector2`, supports `Curve` -- The `AcceleratedParticle` allows basic physics based effects, like gravitation or speed dampening -- The `CircleParticle` renders circles of all shapes and sizes -- The `SpriteParticle` renders Flame `Sprite` within a `Particle` effect -- The `ImageParticle` renders *dart:ui* `Image` within a `Particle` effect -- The `ComponentParticle` renders Flame `Component` within a `Particle` effect -- The `FlareParticle` renders Flare animation within a `Particle` effect +`rotateToVelocity` suits directional particles such as sparks or arrows; +it overrides `rotation` and `spin`. -See more [examples of how to use built-in Particle behaviors -together](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/rendering/particles_example.dart). -All the implementations are available in the [particles folder on the -Flame repository.](https://github.com/flame-engine/flame/tree/main/packages/flame/lib/src/particles) +### Behavior over a particle's lifetime -## TranslatedParticle +Three optional properties change a particle as its life progresses from +0 (spawn) to 1 (death). They are baked into lookup tables when the emitter +is created, so evaluating them per particle per frame is just an array read, +no matter how complex the curve. -Simply translates the underlying `Particle` to a specified `Vector2` within the rendering `Canvas`. -Does not change or alter its position, consider using `MovingParticle` or `AcceleratedParticle` -where change of position is required. Same effect could be achieved by translating the `Canvas` -layer. +`scaleOverLife` multiplies the spawn `size`: ```dart -game.add( - ParticleSystemComponent( - particle: TranslatedParticle( - // Will translate the child Particle effect to the center of game canvas. - offset: game.size / 2, - child: Particle(), - ), - ), -); +size: (10, 20), +scaleOverLife: ParticleCurve(1, 0, curve: Curves.easeOut), // shrink away ``` - -## MovingParticle - -Moves the child `Particle` between the `from` and `to` `Vector2`s during its lifespan. Supports -`Curve` via `CurvedParticle`. +`ParticleCurve` interpolates between two values, optionally shaped by any +Flutter animation `Curve`. `ParticleCurve.constant` holds a value and +`ParticleCurve.custom` bakes an arbitrary function: ```dart -game.add( - ParticleSystemComponent( - particle: MovingParticle( - // Will move from corner to corner of the game canvas. - from: Vector2.zero(), - to: game.size, - child: CircleParticle( - radius: 2.0, - paint: Paint()..color = Colors.red, - ), - ), - ), -); +opacityOverLife: ParticleCurve.custom( + // Fade in during the first 20% of life, out during the rest. + (t) => t < 0.2 ? t / 0.2 : (1 - t) / 0.8, +), ``` - -## AcceleratedParticle - -A basic physics particle which allows you to specify its initial `position`, `speed` and -`acceleration` and lets the `update` cycle do the rest. All three are specified as `Vector2`s, which -you can think of as vectors. It works especially well for physics-based "bursts", but it is not -limited to that. Unit of the `Vector2` value is *logical px/s*. So a speed of `Vector2(0, 100)` will -move a child `Particle` by 100 logical pixels of the device every second of game time. +`colorOverLife` is a `ColorRamp`: a list of colors (optionally with stops) +interpolated over the lifetime. `opacityOverLife` multiplies the ramp's +alpha, so they compose naturally: ```dart -final rnd = Random(); -Vector2 randomVector2() => (Vector2.random(rnd) - Vector2.random(rnd)) * 100; - -game.add( - ParticleSystemComponent( - particle: AcceleratedParticle( - // Will fire off in the center of game canvas - position: game.canvasSize/2, - // With random initial speed of Vector2(-100..100, 0..-100) - speed: Vector2(rnd.nextDouble() * 200 - 100, -rnd.nextDouble() * 100), - // Accelerating downwards, simulating "gravity" - // speed: Vector2(0, 100), - child: CircleParticle( - radius: 2.0, - paint: Paint()..color = Colors.red, - ), - ), - ), -); +colorOverLife: ColorRamp([Colors.white, Colors.orange, Colors.red]), +opacityOverLife: ParticleCurve(1, 0), ``` +With the texture renderers the color tints the texture, so a white texture +takes on the ramp color exactly and sprites stay untinted while the ramp is +unset. -## CircleParticle -A `Particle` which renders a circle with given `Paint` at the zero offset of passed `Canvas`. Use in -conjunction with `TranslatedParticle`, `MovingParticle` or `AcceleratedParticle` in order to achieve -desired positioning. +## Renderers -```dart -game.add( - ParticleSystemComponent( - particle: CircleParticle( - radius: game.size.x / 2, - paint: Paint()..color = Colors.red.withValues(alpha: .5), - ), - ), -); -``` +Renderers describe how particles are drawn and can also be shared between +components. -## SpriteParticle +### CircleParticleRenderer -Allows you to embed a `Sprite` into your particle effects. +Draws every particle as a circle. The circle is rasterized to a texture once +at load; after that each particle is a transformed, tinted copy of it, +drawn in one `drawRawAtlas` batch. `softness` fades the circle from crisp +(0) to fully soft (1), and `blendMode: BlendMode.plus` gives additive glow, +a natural fit for fire and magic: ```dart -game.add( - ParticleSystemComponent( - particle: SpriteParticle( - sprite: Sprite('sprite.png'), - size: Vector2(64, 64), - ), - ), -); +renderer: CircleParticleRenderer(softness: 0.8, blendMode: BlendMode.plus), ``` -## ImageParticle +### SpriteParticleRenderer -Renders given `dart:ui` image within the particle tree. +Draws every particle as a `Sprite` (or a whole image), also fully batched: ```dart -// During game initialization -await Flame.images.loadAll(const [ - 'image.png', -]); - -// ... - -// Somewhere during the game loop -final image = await Flame.images.load('image.png'); - -game.add( - ParticleSystemComponent( - particle: ImageParticle( - size: Vector2.all(24), - image: image, - ); - ), -); +renderer: SpriteParticleRenderer.fromImage(await images.load('spark.png')), +renderer: SpriteParticleRenderer(sprite), // a region of a sprite sheet ``` +Particles are drawn centered, rotated by their rotation, and scaled so the +sprite's width matches the particle's current size. + -## ScalingParticle +### Custom rendering -Scales the child `Particle` between `1` and `to` during its lifespan. +For full control, use `CallbackParticleRenderer` (or extend +`ParticleRenderer`). The callback receives the canvas, already in the +component's local coordinate system, and the `ParticleBuffer` holding all +live particle state as flat typed-data arrays: ```dart -game.add( - ParticleSystemComponent( - particle: ScalingParticle( - lifespan: 2, - to: 0, - curve: Curves.easeIn, - child: CircleParticle( - radius: 2.0, - paint: Paint()..color = Colors.red, - ) +renderer: CallbackParticleRenderer((canvas, particles) { + for (var i = 0; i < particles.length; i++) { + canvas.drawCircle( + Offset(particles.posX[i], particles.posY[i]), + particles.size[i] / 2, + paint, ); - ), -); + } +}), ``` +Nothing is batched in a callback renderer, so prefer the texture renderers +for large particle counts. -## SpriteAnimationParticle -A `Particle` which embeds a `SpriteAnimation`. -By default, aligns the `SpriteAnimation`'s `stepTime` so that -it's fully played during the `Particle` lifespan. It's possible to override this behavior with the -`alignAnimationTime` argument. +## ParticleEmitterComponent + +`ParticleEmitterComponent` is a regular `PositionComponent`: position it, +give it a priority, add it anywhere in the component tree. It exposes +runtime control over the effect: ```dart -final spriteSheet = SpriteSheet( - image: yourSpriteSheetImage, - srcSize: Vector2.all(16.0), +final effect = ParticleEmitterComponent( + emitter: smokePreset, + renderer: CircleParticleRenderer(softness: 0.9), + emitting: false, // start paused ); -game.add( - ParticleSystemComponent( - particle: SpriteAnimationParticle( - animation: spriteSheet.createAnimation(0, stepTime: 0.1), - ); - ), -); +effect.start(); // run the emission timeline (restarts if finished) +effect.stop(); // pause emission; live particles keep simulating +effect.emit(30); // spawn 30 particles right now, timeline-independent +effect.clearParticles(); +effect.particleCount; // live particles ``` - -## ComponentParticle - -This `Particle` allows you to embed a `Component` within the particle effects. The `Component` could -have its own `update` lifecycle and could be reused across different effect trees. If the only thing -you need is to add some dynamics to an instance of a certain `Component`, please consider adding it -to the `game` directly, without the `Particle` in the middle. +Pass a seeded `Random` for deterministic effects (useful in tests and +replays): ```dart -final longLivingRect = RectComponent(); - -game.add( - ParticleSystemComponent( - particle: ComponentParticle( - component: longLivingRect - ); - ), -); - -class RectComponent extends Component { - void render(Canvas c) { - c.drawRect( - Rect.fromCenter(center: Offset.zero, width: 100, height: 100), - Paint()..color = Colors.red - ); - } - - void update(double dt) { - /// Will be called by parent [Particle] - } -} +ParticleEmitterComponent(emitter: e, renderer: r, random: Random(42)); ``` -## ComputedParticle +### Lifecycle -A `Particle` which could help you when: - -- Default behavior is not enough -- Complex effects optimization -- Custom easings - -When created, it delegates all the rendering to a supplied `ParticleRenderDelegate` which is called -on each frame to perform necessary computations and render something to the `Canvas`. +With `removeOnFinish` (the default), the component removes itself once +emission has naturally finished, meaning the timeline completed without +looping, and the last particle has died. Endless emitters are never removed +automatically. This makes one-shot effects fire-and-forget: ```dart -game.add( - ParticleSystemComponent( - // Renders a circle which gradually changes its color and size during the - // particle lifespan. - particle: ComputedParticle( - renderer: (canvas, particle) => canvas.drawCircle( - Offset.zero, - particle.progress * 10, - Paint() - ..color = Color.lerp( - Colors.red, - Colors.blue, - particle.progress, - ), - ), - ), +world.add( + ParticleEmitterComponent( + position: hitPosition, + emitter: sparksPreset, // burst-only preset, shared by all hits + renderer: sparksRenderer, ), -) +); ``` -## Nesting behavior - -Flame's implementation of particles follows the same pattern of extreme composition as Flutter -widgets. That is achieved by encapsulating small pieces of behavior in every particle and then -nesting these behaviors together to achieve the desired visual effect. +### Moving emitters and trails -Two entities that allow `Particle`s to nest each other are: `SingleChildParticle` mixin and -`ComposedParticle` class. - -A `SingleChildParticle` may help you with creating `Particles` with a custom behavior. For example, -randomly positioning its child during each frame: - -The `SingleChildParticle` may help you with creating `Particles` with a custom behavior. - -For example, randomly positioning it's child during each frame: +Particles simulate in the component's local space, so moving the component +moves live particles with it. For trails, exhaust, and similar effects set +`worldSpace: true`: already-spawned particles then keep their world +position while the emitter moves on. ```dart -var rnd = Random(); - -class GlitchParticle extends Particle with SingleChildParticle { - Particle child; - - GlitchParticle({ - required this.child, - super.lifespan, - }); +ship.add( + ParticleEmitterComponent( + position: exhaustOffset, + emitter: exhaustPreset, + renderer: CircleParticleRenderer(softness: 1), + worldSpace: true, + ), +); +``` - @override - render(Canvas canvas) { - canvas.save(); - canvas.translate(rnd.nextDouble() * 100, rnd.nextDouble() * 100); +Only translation is compensated: the component and its ancestors should not +be rotated or scaled while `worldSpace` is enabled. - // Will also render the child - super.render(); - canvas.restore(); - } -} -``` +## Performance notes -The `ComposedParticle` could be used either as a standalone or within an existing `Particle` tree. +- All per-particle state is stored in `Float32List`/`Int32List` columns + (a struct-of-arrays layout); simulation is a tight loop over contiguous + memory and dead particles are swap-removed in constant time. +- The texture renderers issue a single `Canvas.drawRawAtlas` call per + component per frame, regardless of particle count. +- Over-life curves and color ramps are baked into lookup tables at emitter + construction. +- Storage is allocated once, at `maxParticles`; a running effect performs + no allocations. Reuse a paused component and call `emit()` instead of + spawning new components when an effect fires very frequently. diff --git a/examples/lib/stories/rendering/particles_example.dart b/examples/lib/stories/rendering/particles_example.dart index 86aa82aa383..456db2c1a6a 100644 --- a/examples/lib/stories/rendering/particles_example.dart +++ b/examples/lib/stories/rendering/particles_example.dart @@ -1,565 +1,427 @@ -import 'dart:async'; import 'dart:math'; +import 'dart:ui' as ui; -import 'package:flame/components.dart' hide Timer; +import 'package:flame/components.dart'; import 'package:flame/game.dart'; +import 'package:flame/geometry.dart'; import 'package:flame/particles.dart'; -import 'package:flame/sprite.dart'; -import 'package:flame/timer.dart' as flame_timer; import 'package:flutter/material.dart' hide Image; class ParticlesExample extends FlameGame { static const String description = ''' - In this example we show how to render a lot of different particles. + Showcases the declarative particle system. Every effect is built from + ParticleEmitterComponents: reusable ParticleEmitter presets describe what + to spawn (rate, bursts, shapes, velocities) and how particles evolve + (gravity, drag, scale, opacity and color over life), while renderers draw + all particles of an emitter in a single batched draw call. Watch the + live particle counter: everything runs from preallocated buffers. '''; - /// Defines dimensions of the sample - /// grid to be displayed on the screen, - /// 5x5 in this particular case - static const gridSize = 5.0; - static const steps = 5; - - /// Miscellaneous values used - /// by examples below - final Random rnd = Random(); - Timer? spawnTimer; - final StepTween steppedTween = StepTween(begin: 0, end: 5); - final trafficLight = TrafficLightComponent(); - - /// Defines the lifespan of all the particles in these examples - final sceneDuration = const Duration(seconds: 1); - - Vector2 get cellSize => size / gridSize; - Vector2 get halfCellSize => cellSize / 2; + static const int _columns = 3; + static const int _rows = 3; @override Future onLoad() async { - await images.load('zap.png'); - await images.load('boom.png'); - } - - @override - void onMount() { - spawnParticles(); - // Spawn new particles every second - spawnTimer = Timer.periodic(sceneDuration, (_) { - spawnParticles(); - }); - } - - @override - void onRemove() { - super.onRemove(); - spawnTimer?.cancel(); - } - - /// Showcases various different uses of [Particle] - /// and its derivatives - void spawnParticles() { - // Contains sample particles, in order by complexity - // and amount of used features. Jump to source for more explanation on each - final particles = [ - circle(), - smallWhiteCircle(), - movingParticle(), - randomMovingParticle(), - alignedMovingParticles(), - easedMovingParticle(), - intervalMovingParticle(), - computedParticle(), - chainingBehaviors(), - steppedComputedParticle(), - reuseParticles(), - imageParticle(), - reuseImageParticle(), - rotatingImage(), - acceleratedParticles(), - paintParticle(), - spriteParticle(), - animationParticle(), - fireworkParticle(), - componentParticle(), + final zap = await images.load('zap.png'); + final cell = Vector2(size.x / _columns, size.y / _rows); + + final effects = <(String, PositionComponent)>[ + ('Fountain', _fountain()), + ('Explosion', _explosion()), + ('Inferno', _inferno()), + ('Snow', _snow(cell)), + ('Confetti', _confetti(zap)), + ('Portal', _portal()), + ('Sparks', _sparks(zap)), + ('Comet', _comet(cell)), + ('Velocity streaks', _velocityStreaks()), ]; - // Place all the [Particle] instances - // defined above in a grid on the screen - // as per defined grid parameters - do { - final particle = particles.removeLast(); - final col = particles.length % gridSize; - final row = (particles.length ~/ gridSize).toDouble(); - final cellCenter = (cellSize..multiply(Vector2(col, row))) + halfCellSize; - + for (final (index, (label, component)) in effects.indexed) { + final column = index % _columns; + final row = index ~/ _columns; + final center = Vector2( + (column + 0.5) * cell.x, + (row + 0.5) * cell.y, + ); + add(component..position = center); add( - // Bind all the particles to a [Component] update - // lifecycle from the [FlameGame]. - ParticleSystemComponent( - particle: TranslatedParticle( - lifespan: 1, - offset: cellCenter, - child: particle, + TextComponent( + text: label, + anchor: Anchor.bottomCenter, + position: Vector2(center.x, (row + 1) * cell.y - 6), + textRenderer: TextPaint( + style: const TextStyle(color: Colors.white38, fontSize: 12), ), ), ); - } while (particles.isNotEmpty); - } - - /// Simple static circle, doesn't move or - /// change any of its attributes - Particle circle() { - return CircleParticle( - paint: Paint()..color = Colors.white10, + } + + // Top-center, so it does not collide with the Dashbook menu button that + // overlays the top-left corner. + add( + _StatsText( + position: Vector2(size.x / 2, 8), + anchor: Anchor.topCenter, + ), ); } - /// This one will is a bit smaller, - /// and a bit less transparent - Particle smallWhiteCircle() { - return CircleParticle( - radius: 5.0, - paint: Paint()..color = Colors.white, + /// A continuous jet of glowing droplets pulled back down by gravity. + PositionComponent _fountain() { + return ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 250, + lifespan: (1, 1.7), + speed: (130, 210), + direction: -tau / 4, + spread: tau / 14, + gravity: Vector2(0, 220), + size: (2, 6), + colorOverLife: ColorRamp( + const [Colors.white, Colors.cyanAccent, Colors.blueAccent], + ), + opacityOverLife: ParticleCurve(1, 0, curve: Curves.easeIn), + ), + renderer: CircleParticleRenderer( + softness: 0.4, + blendMode: BlendMode.plus, + ), ); } - /// Particle which is moving from - /// one predefined position to another one - Particle movingParticle() { - return MovingParticle( - /// This parameter is optional, will default to [Vector2.zero] - from: Vector2(-20, -20), - to: Vector2(20, 20), - child: CircleParticle(paint: Paint()..color = Colors.amber), + /// A repeating explosion layered from a bright flash and hot debris that + /// drag slows down as it cools off. + PositionComponent _explosion() { + final flash = ParticleEmitter( + bursts: const [EmitterBurst(0, 1)], + duration: 1.5, + loop: true, + lifespan: (0.25, 0.25), + size: (60, 60), + scaleOverLife: ParticleCurve(0.3, 2.2, curve: Curves.easeOutExpo), + colorOverLife: ColorRamp(const [Colors.white, Colors.orangeAccent]), + opacityOverLife: ParticleCurve(1, 0), ); - } - - /// [Particle] which is moving to a random direction - /// within each cell each time created - Particle randomMovingParticle() { - return MovingParticle( - to: randomCellVector2(), - child: CircleParticle( - radius: 5 + rnd.nextDouble() * 5, - paint: Paint()..color = Colors.red, + final debris = ParticleEmitter( + bursts: const [EmitterBurst(0, 250)], + duration: 1.5, + loop: true, + lifespan: (0.5, 1.4), + speed: (40, 300), + drag: 2.5, + size: (1.5, 5), + scaleOverLife: ParticleCurve(1, 0, curve: Curves.easeOut), + colorOverLife: ColorRamp( + const [ + Colors.white, + Colors.yellowAccent, + Colors.deepOrange, + Colors.red, + ], ), ); - } - - /// Generates 5 particles, each moving - /// symmetrically within grid cell - Particle alignedMovingParticles() { - return Particle.generate( - count: 5, - generator: (i) { - final currentColumn = (cellSize.x / 5) * i - halfCellSize.x; - return MovingParticle( - from: Vector2(currentColumn, -halfCellSize.y), - to: Vector2(currentColumn, halfCellSize.y), - child: CircleParticle( - radius: 2.0, - paint: Paint()..color = Colors.blue, - ), - ); - }, + final glow = CircleParticleRenderer( + softness: 0.5, + blendMode: BlendMode.plus, + ); + return PositionComponent( + children: [ + ParticleEmitterComponent(emitter: flash, renderer: glow), + ParticleEmitterComponent(emitter: debris, renderer: glow), + ], ); } - /// Burst of 5 particles each moving - /// to a random direction within the cell - Particle randomMovingParticles() { - return Particle.generate( - count: 5, - generator: (i) => MovingParticle( - to: randomCellVector2()..scale(0.5), - child: CircleParticle( - radius: 5 + rnd.nextDouble() * 5, - paint: Paint()..color = Colors.deepOrange, + /// Flames, drifting smoke, and crackling embers layered into one bonfire. + PositionComponent _inferno() { + final flames = ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 140, + lifespan: (0.5, 1.1), + shape: const CircleEmitterShape(14), + speed: (50, 110), + direction: -tau / 4, + spread: tau / 10, + size: (14, 30), + scaleOverLife: ParticleCurve(1, 0.2, curve: Curves.easeOut), + colorOverLife: ColorRamp( + const [Colors.white, Colors.amber, Colors.deepOrange, Colors.red], ), + opacityOverLife: ParticleCurve(0.9, 0), + ), + renderer: CircleParticleRenderer( + softness: 0.8, + blendMode: BlendMode.plus, ), ); - } - - /// Same example as above, but with easing, utilizing [CurvedParticle] - /// extension. - Particle easedMovingParticle() { - return Particle.generate( - count: 5, - generator: (i) => MovingParticle( - curve: Curves.easeOutQuad, - to: randomCellVector2()..scale(0.5), - child: CircleParticle( - radius: 5 + rnd.nextDouble() * 5, - paint: Paint()..color = Colors.deepPurple, + final smoke = ParticleEmitterComponent( + position: Vector2(0, -40), + emitter: ParticleEmitter( + rate: 20, + lifespan: (1.5, 2.5), + shape: const CircleEmitterShape(10), + speed: (25, 50), + direction: -tau / 4, + spread: tau / 8, + size: (18, 30), + scaleOverLife: ParticleCurve(1, 2.8), + colorOverLife: ColorRamp.solid(Colors.blueGrey.shade700), + opacityOverLife: ParticleCurve.custom( + (t) => t < 0.2 ? t / 0.2 * 0.4 : (1 - t) / 0.8 * 0.4, ), ), + renderer: CircleParticleRenderer(softness: 0.9), ); - } - - /// Same example as above, but using awesome [Interval] - /// curve, which "schedules" transition to happen between - /// certain values of progress. In this example, circles will - /// move from their initial to their final position - /// when progress is changing from 0.2 to 0.6 respectively. - Particle intervalMovingParticle() { - return Particle.generate( - count: 5, - generator: (i) => MovingParticle( - curve: const Interval(0.2, 0.6, curve: Curves.easeInOutCubic), - to: randomCellVector2()..scale(0.5), - child: CircleParticle( - radius: 5 + rnd.nextDouble() * 5, - paint: Paint()..color = Colors.greenAccent, + final embers = ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 12, + lifespan: (1, 2), + shape: const CircleEmitterShape(16), + speed: (30, 90), + direction: -tau / 4, + spread: tau / 7, + gravity: Vector2(0, -30), + size: (1.5, 3.5), + colorOverLife: ColorRamp( + const [Colors.white, Colors.orangeAccent, Colors.red], ), + opacityOverLife: ParticleCurve.custom((t) => 0.5 + 0.5 * sin(t * 25)), ), + renderer: CircleParticleRenderer(blendMode: BlendMode.plus), ); - } - - /// A [ComputedParticle] completely delegates all the rendering - /// to an external function, hence It's very flexible, as you can implement - /// any currently missing behavior with it. - /// Also, it allows to optimize complex behaviors by avoiding nesting too - /// many [Particle] together and having all the computations in place. - Particle computedParticle() { - return ComputedParticle( - renderer: (canvas, particle) => canvas.drawCircle( - Offset.zero, - particle.progress * halfCellSize.x, - Paint() - ..color = Color.lerp( - Colors.red, - Colors.blue, - particle.progress, - )!, + return PositionComponent(children: [smoke, flames, embers]); + } + + /// Soft flakes drifting down across the whole cell. + PositionComponent _snow(Vector2 cell) { + return ParticleEmitterComponent( + position: Vector2(0, -cell.y / 2), + emitter: ParticleEmitter( + rate: 40, + lifespan: (3, 5), + shape: RectangleEmitterShape(cell.x * 0.9, 4), + speed: (25, 60), + direction: tau / 4, + spread: tau / 24, + size: (2, 7), + opacityOverLife: ParticleCurve.custom( + (t) => t < 0.1 ? t / 0.1 : (1 - t) / 0.9, + ), ), + renderer: CircleParticleRenderer(softness: 0.6), ); } - /// Using [ComputedParticle] to use custom tweening - /// In reality, you would like to keep as much of renderer state - /// defined outside and reused between each call - Particle steppedComputedParticle() { - return ComputedParticle( - lifespan: 2, - renderer: (canvas, particle) { - const steps = 5; - final steppedProgress = - steppedTween.transform(particle.progress) / steps; + /// Three tinted sprite emitters make multicolored, tumbling confetti. + PositionComponent _confetti(ui.Image zap) { + ParticleEmitterComponent emitterWithColor(Color color) { + return ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 12, + lifespan: (1.4, 2.2), + shape: const RectangleEmitterShape(130, 10), + speed: (10, 40), + direction: tau / 4, + spread: tau / 12, + gravity: Vector2(0, 70), + size: (8, 16), + rotation: (0, tau), + spin: (-9, 9), + colorOverLife: ColorRamp.solid(color), + opacityOverLife: ParticleCurve(1, 0, curve: Curves.easeInExpo), + ), + renderer: SpriteParticleRenderer.fromImage(zap), + ); + } - canvas.drawCircle( - Offset.zero, - (1 - steppedProgress) * halfCellSize.x, - Paint() - ..color = Color.lerp( - Colors.red, - Colors.blue, - steppedProgress, - )!, - ); - }, + return PositionComponent( + position: Vector2(0, -60), + children: [ + emitterWithColor(Colors.pinkAccent), + emitterWithColor(Colors.tealAccent), + emitterWithColor(Colors.amberAccent), + ], ); } - /// Particle which is used in example below - Particle? reusableParticle; - - /// A burst of white circles which actually using a single circle - /// as a form of optimization. Look for reusing parts of particle effects - /// whenever possible, as there are limits which are relatively easy to reach. - Particle reuseParticles() { - reusableParticle ??= circle(); - - return Particle.generate( - generator: (i) => MovingParticle( - curve: Interval(rnd.nextDouble() * 0.1, rnd.nextDouble() * 0.8 + 0.1), - to: randomCellVector2()..scale(0.5), - child: reusableParticle!, + /// A glowing ring of particles around a pulsing core. + PositionComponent _portal() { + final ring = ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 350, + lifespan: (0.4, 0.9), + shape: const CircleEmitterShape(45, edgeOnly: true), + speed: (3, 22), + size: (1.5, 4), + colorOverLife: ColorRamp( + const [Colors.white, Colors.tealAccent, Colors.indigoAccent], + ), + opacityOverLife: ParticleCurve(1, 0), + ), + renderer: CircleParticleRenderer( + softness: 0.4, + blendMode: BlendMode.plus, ), ); - } - - /// Simple static image particle which doesn't do much. - /// Images are great examples of where assets should - /// be reused across particles. See example below for more details. - Particle imageParticle() { - return ImageParticle( - size: Vector2.all(24), - image: images.fromCache('zap.png'), - ); - } - - /// Particle which is used in example below - Particle? reusableImageParticle; - - /// A single [imageParticle] is drawn 9 times - /// in a grid within grid cell. Looks as 9 particles - /// to user, saves us 8 particle objects. - Particle reuseImageParticle() { - const count = 9; - const perLine = 3; - const imageSize = 24.0; - final colWidth = cellSize.x / perLine; - final rowHeight = cellSize.y / perLine; - - reusableImageParticle ??= imageParticle(); - - return Particle.generate( - count: count, - generator: (i) => TranslatedParticle( - offset: Vector2( - (i % perLine) * colWidth - halfCellSize.x + imageSize, - (i ~/ perLine) * rowHeight - halfCellSize.y + imageSize, - ), - child: reusableImageParticle!, + final core = ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 20, + lifespan: (0.6, 1.2), + size: (25, 45), + scaleOverLife: ParticleCurve(0.4, 1.4, curve: Curves.easeOut), + colorOverLife: ColorRamp(const [Colors.tealAccent, Colors.indigo]), + opacityOverLife: ParticleCurve(0.35, 0), + ), + renderer: CircleParticleRenderer( + softness: 1, + blendMode: BlendMode.plus, ), ); - } - - /// [RotatingParticle] is a simple container which rotates - /// a child particle passed to it. - /// As you can see, we're reusing [imageParticle] from example above. - /// Such a composability is one of the main implementation features. - Particle rotatingImage({double initialAngle = 0}) { - return RotatingParticle(from: initialAngle, child: imageParticle()); - } - - /// [AcceleratedParticle] is a very basic acceleration physics container, - /// which could help implementing such behaviors as gravity, or adding - /// some non-linearity to something like [MovingParticle] - Particle acceleratedParticles() { - return Particle.generate( - generator: (i) => AcceleratedParticle( - speed: - Vector2( - rnd.nextDouble() * 600 - 300, - -rnd.nextDouble() * 600, - ) * - 0.2, - acceleration: Vector2(0, 200), - child: rotatingImage(initialAngle: rnd.nextDouble() * pi), + return PositionComponent(children: [core, ring]); + } + + /// Fast directional sprites that rotate to follow their velocity as + /// gravity bends their path. + PositionComponent _sparks(ui.Image zap) { + return ParticleEmitterComponent( + emitter: ParticleEmitter( + bursts: const [EmitterBurst(0, 25), EmitterBurst(0.5, 25)], + duration: 1, + loop: true, + lifespan: (0.5, 0.9), + speed: (160, 300), + direction: -tau / 4, + spread: tau / 6, + gravity: Vector2(0, 450), + size: (10, 16), + rotateToVelocity: true, + opacityOverLife: ParticleCurve(1, 0), ), + renderer: SpriteParticleRenderer.fromImage(zap), ); } - /// [PaintParticle] allows to perform basic composite operations - /// by specifying custom [Paint]. - /// Be aware that it's very easy to get *really* bad performance - /// misusing composites. - Particle paintParticle() { - final colors = [ - const Color(0xffff0000), - const Color(0xff00ff00), - const Color(0xff0000ff), - ]; - final positions = [ - Vector2(-10, 10), - Vector2(10, 10), - Vector2(0, -14), - ]; - - return Particle.generate( - count: 3, - generator: (i) => PaintParticle( - paint: Paint()..blendMode = BlendMode.difference, - child: MovingParticle( - curve: SineCurve(), - from: positions[i], - to: i == 0 ? positions.last : positions[i - 1], - child: CircleParticle( - radius: 20.0, - paint: Paint()..color = colors[i], + /// A moving emitter with worldSpace enabled: spawned particles stay + /// behind, forming a glowing tail. + PositionComponent _comet(Vector2 cell) { + return _OrbitingEmitter( + orbit: Vector2(cell.x / 3, cell.y / 4), + child: ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 350, + lifespan: (0.3, 0.8), + shape: const CircleEmitterShape(3), + speed: (0, 12), + size: (3, 9), + scaleOverLife: ParticleCurve(1, 0), + colorOverLife: ColorRamp( + const [Colors.white, Colors.purpleAccent, Colors.indigo], ), + opacityOverLife: ParticleCurve(0.9, 0), + ), + renderer: CircleParticleRenderer( + softness: 0.6, + blendMode: BlendMode.plus, ), + worldSpace: true, ), ); } - /// [SpriteParticle] allows easily embed - /// Flame's [Sprite] into the effect. - Particle spriteParticle() { - return SpriteParticle( - sprite: Sprite(images.fromCache('zap.png')), - size: cellSize * 0.5, - ); - } - - /// An [SpriteAnimationParticle] takes a Flame [SpriteAnimation] - /// and plays it during the particle lifespan. - Particle animationParticle() { - return SpriteAnimationParticle( - animation: getBoomAnimation(), - size: Vector2(128, 128), - ); - } - - /// [ComponentParticle] proxies particle lifecycle hooks - /// to its child [Component]. In example below, [Component] is - /// reused between particle effects and has internal behavior - /// which is independent from the parent [Particle]. - Particle componentParticle() { - return MovingParticle( - from: -halfCellSize * 0.2, - to: halfCellSize * 0.2, - curve: SineCurve(), - child: ComponentParticle(component: trafficLight), - ); - } - - /// Not very realistic firework, yet it highlights - /// use of [ComputedParticle] within other particles, - /// mixing predefined and fully custom behavior. - Particle fireworkParticle() { - // A palette to paint over the "sky" - final paints = [ - Colors.amber, - Colors.amberAccent, - Colors.red, - Colors.redAccent, - Colors.yellow, - Colors.yellowAccent, - // Adds a nice "lense" tint - // to overall effect - Colors.blue, - ].map((color) => Paint()..color = color).toList(); - - return Particle.generate( - generator: (i) { - final initialSpeed = randomCellVector2(); - final deceleration = initialSpeed * -1; - final gravity = Vector2(0, 40); - - return AcceleratedParticle( - speed: initialSpeed, - acceleration: deceleration + gravity, - child: ComputedParticle( - renderer: (canvas, particle) { - final paint = randomElement(paints); - // Override the color to dynamically update opacity - paint.color = paint.color.withValues( - alpha: 1 - particle.progress, - ); - - canvas.drawCircle( - Offset.zero, - // Closer to the end of lifespan particles - // will turn into larger glaring circles - rnd.nextDouble() * particle.progress > 0.6 - ? rnd.nextDouble() * (50 * particle.progress) - : 2 + (3 * particle.progress), - paint, - ); - }, - ), - ); - }, - ); - } - - /// [Particle] base class exposes a number - /// of convenience wrappers to make positioning. - /// - /// Just remember that the less chaining and nesting - the - /// better for performance! - Particle chainingBehaviors() { - final paint = Paint()..color = randomMaterialColor(); - final rect = ComputedParticle( - renderer: (canvas, _) => canvas.drawRect( - Rect.fromCenter(center: Offset.zero, width: 10, height: 10), - paint, + /// Full access to the raw particle buffer through a + /// CallbackParticleRenderer, here drawing every particle as a motion + /// streak along its velocity. + PositionComponent _velocityStreaks() { + final paint = Paint() + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..blendMode = BlendMode.plus; + return ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 120, + lifespan: (0.8, 1.6), + shape: const CircleEmitterShape(10), + speed: (40, 140), + drag: 1.2, + size: (2, 4), ), + renderer: CallbackParticleRenderer((canvas, particles) { + for (var i = 0; i < particles.length; i++) { + final progress = particles.progressAt(i); + paint.color = Color.lerp( + Colors.limeAccent, + Colors.transparent, + progress, + )!; + final x = particles.posX[i]; + final y = particles.posY[i]; + canvas.drawLine( + Offset(x, y), + Offset( + x - particles.velX[i] * 0.08, + y - particles.velY[i] * 0.08, + ), + paint, + ); + } + }), ); - - return ComposedParticle( - children: [ - rect - .rotating(to: pi / 2) - .moving(to: -cellSize) - .scaled(2) - .accelerated(acceleration: halfCellSize * 5) - .translated(halfCellSize), - rect - .rotating(to: -pi) - .moving(to: Vector2(1, -1)..multiply(cellSize)) - .scaled(2) - .translated(Vector2(1, -1)..multiply(halfCellSize)) - .accelerated(acceleration: Vector2(-5, 5)..multiply(halfCellSize)), - ], - ); - } - - /// Returns random [Vector2] within a virtual grid cell - Vector2 randomCellVector2() { - return (Vector2.random() - Vector2.random())..multiply(cellSize); - } - - /// Returns random [Color] from primary swatches - /// of material palette - Color randomMaterialColor() { - return Colors.primaries[rnd.nextInt(Colors.primaries.length)]; - } - - /// Returns a random element from a given list - T randomElement(List list) { - return list[rnd.nextInt(list.length)]; - } - - /// Sample "explosion" animation for [SpriteAnimationParticle] example - SpriteAnimation getBoomAnimation() { - const columns = 8; - const rows = 8; - const frames = columns * rows; - final spriteImage = images.fromCache('boom.png'); - final spriteSheet = SpriteSheet.fromColumnsAndRows( - image: spriteImage, - columns: columns, - rows: rows, - ); - final sprites = List.generate(frames, spriteSheet.getSpriteById); - return SpriteAnimation.spriteList(sprites, stepTime: 0.1); } } -Future loadGame() async { - WidgetsFlutterBinding.ensureInitialized(); +/// Moves its emitter child along a Lissajous-like path. +class _OrbitingEmitter extends PositionComponent { + _OrbitingEmitter({required this.orbit, required PositionComponent child}) { + add(child); + } - return ParticlesExample(); -} + final Vector2 orbit; + late final Vector2 _center = position.clone(); + double _time = 0; -/// A curve which maps sinus output (-1..1,0..pi) -/// to an oscillating (0..1..0,0..1), essentially "ease-in-out and back" -class SineCurve extends Curve { @override - double transformInternal(double t) { - return (sin(pi * (t * 2 - 1 / 2)) + 1) / 2; + void update(double dt) { + super.update(dt); + _time += dt; + position.setValues( + _center.x + cos(_time * 2) * orbit.x, + _center.y + sin(_time * 3) * orbit.y, + ); } } -/// Sample for [ComponentParticle], changes its colors -/// each 2s of registered lifetime. -class TrafficLightComponent extends Component { - final Rect rect = Rect.fromCenter(center: Offset.zero, height: 32, width: 32); - final flame_timer.Timer colorChangeTimer = flame_timer.Timer(2, repeat: true); - final colors = [ - Colors.green, - Colors.orange, - Colors.red, - ]; - final Paint _paint = Paint(); +/// Displays the frame rate and the total number of live particles across +/// all emitters, refreshed a few times per second. +class _StatsText extends TextComponent with HasGameReference { + _StatsText({super.position, super.anchor}) + : super( + textRenderer: TextPaint( + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ); - @override - void onMount() { - colorChangeTimer.start(); - } + final FpsComponent _fps = FpsComponent(); + double _sinceRefresh = 1; @override - void render(Canvas canvas) { - canvas.drawRect(rect, _paint..color = currentColor); + Future onLoad() async { + add(_fps); } @override void update(double dt) { - colorChangeTimer.update(dt); - } - - Color get currentColor { - return colors[(colorChangeTimer.progress * colors.length).toInt()]; + super.update(dt); + _sinceRefresh += dt; + if (_sinceRefresh < 0.25) { + return; + } + _sinceRefresh = 0; + var count = 0; + for (final emitter + in game.descendants().whereType()) { + count += emitter.particleCount; + } + text = '${_fps.fps.round()} fps, $count particles'; } } diff --git a/examples/lib/stories/rendering/particles_interactive_example.dart b/examples/lib/stories/rendering/particles_interactive_example.dart index c88ebe94412..532b283f14b 100644 --- a/examples/lib/stories/rendering/particles_interactive_example.dart +++ b/examples/lib/stories/rendering/particles_interactive_example.dart @@ -1,60 +1,254 @@ -import 'dart:math'; +import 'dart:ui' as ui; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; -import 'package:flame/input.dart'; +import 'package:flame/geometry.dart'; import 'package:flame/particles.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Image; class ParticlesInteractiveExample extends FlameGame with PanDetector { static const description = - 'An example which shows how ' - 'ParticleSystemComponent can be added in runtime ' - 'following an event, in this example, the mouse ' - 'dragging'; + 'Drag around the canvas to paint with particles, and pick an effect in ' + 'the properties panel (the knobs icon in the top right) to try the ' + 'different emitter presets and renderers. A single pooled ' + 'ParticleEmitterComponent follows the pointer: worldSpace keeps ' + 'already-spawned particles in place while emit() releases more from ' + 'the preallocated buffer, so no objects are allocated while you draw.'; - final random = Random(); - final Tween noise = Tween(begin: -1, end: 1); - final ColorTween colorTween; + /// The effect presets selectable in the properties panel. + static const List effects = [ + 'Sparkles', + 'Fire', + 'Smoke', + 'Explosions', + 'Sparks', + 'Confetti', + 'Streaks', + 'Bubbles', + ]; ParticlesInteractiveExample({ - required Color from, - required Color to, + required this.effect, required double zoom, - }) : colorTween = ColorTween(begin: from, end: to), - super( + }) : super( camera: CameraComponent.withFixedResolution( width: 400, height: 600, )..viewfinder.zoom = zoom, ); + /// The name of the selected effect preset, one of [effects]. + final String effect; + + late ParticleEmitterComponent _emitter; + + /// How many particles are released when a drag starts and on every drag + /// update; tuned per preset. + int _perDragStart = 0; + int _perDragUpdate = 20; + + @override + Future onLoad() async { + final zap = await images.load('zap.png'); + _emitter = _buildEffect(zap); + add(_emitter); + } + + @override + void onPanStart(DragStartInfo info) { + _emitter.position = info.eventPosition.widget; + _emitter.emit(_perDragStart); + } + @override void onPanUpdate(DragUpdateInfo info) { - add( - ParticleSystemComponent( - position: info.eventPosition.widget, - particle: Particle.generate( - count: 40, - generator: (i) { - return AcceleratedParticle( - lifespan: 2, - speed: - Vector2( - noise.transform(random.nextDouble()), - noise.transform(random.nextDouble()), - ) * - i.toDouble(), - child: CircleParticle( - radius: 2, - paint: Paint() - ..color = colorTween.transform(random.nextDouble())!, - ), - ); - }, - ), - ), + _emitter.position = info.eventPosition.widget; + _emitter.emit(_perDragUpdate); + } + + /// All presets share the same pooled setup: emission is driven purely by + /// emit() from the drag callbacks, and worldSpace leaves spawned + /// particles behind as the emitter follows the pointer. + ParticleEmitterComponent _pooled( + ParticleEmitter emitter, + ParticleRenderer renderer, + ) { + return ParticleEmitterComponent( + emitter: emitter, + renderer: renderer, + emitting: false, + removeOnFinish: false, + worldSpace: true, ); } + + ParticleEmitterComponent _buildEffect(ui.Image zap) { + switch (effect) { + case 'Fire': + _perDragUpdate = 6; + return _pooled( + ParticleEmitter( + maxParticles: 4096, + lifespan: (0.4, 1), + shape: const CircleEmitterShape(6), + speed: (20, 70), + direction: -tau / 4, + spread: tau / 6, + size: (10, 24), + scaleOverLife: ParticleCurve(1, 0.3), + colorOverLife: ColorRamp( + const [Colors.white, Colors.amber, Colors.deepOrange, Colors.red], + ), + opacityOverLife: ParticleCurve(0.9, 0), + ), + CircleParticleRenderer(softness: 0.8, blendMode: BlendMode.plus), + ); + case 'Smoke': + _perDragUpdate = 4; + return _pooled( + ParticleEmitter( + maxParticles: 2048, + lifespan: (1.5, 3), + shape: const CircleEmitterShape(6), + speed: (10, 40), + direction: -tau / 4, + spread: tau / 5, + drag: 0.5, + size: (14, 24), + scaleOverLife: ParticleCurve(1, 2.5), + colorOverLife: ColorRamp.solid(Colors.blueGrey), + opacityOverLife: ParticleCurve.custom( + (t) => t < 0.2 ? t / 0.2 * 0.4 : (1 - t) / 0.8 * 0.4, + ), + ), + CircleParticleRenderer(softness: 0.9), + ); + case 'Explosions': + _perDragStart = 120; + _perDragUpdate = 5; + return _pooled( + ParticleEmitter( + maxParticles: 8192, + lifespan: (0.4, 1.2), + speed: (40, 260), + drag: 2.5, + size: (1.5, 5), + scaleOverLife: ParticleCurve(1, 0, curve: Curves.easeOut), + colorOverLife: ColorRamp( + const [ + Colors.white, + Colors.yellowAccent, + Colors.deepOrange, + Colors.red, + ], + ), + ), + CircleParticleRenderer(softness: 0.5, blendMode: BlendMode.plus), + ); + case 'Sparks': + _perDragUpdate = 6; + return _pooled( + ParticleEmitter( + maxParticles: 4096, + lifespan: (0.5, 1), + speed: (100, 300), + gravity: Vector2(0, 500), + size: (8, 14), + rotateToVelocity: true, + opacityOverLife: ParticleCurve(1, 0), + ), + SpriteParticleRenderer.fromImage(zap), + ); + case 'Confetti': + _perDragUpdate = 6; + return _pooled( + ParticleEmitter( + maxParticles: 4096, + lifespan: (1, 2), + speed: (20, 90), + gravity: Vector2(0, 150), + drag: 0.5, + size: (6, 14), + rotation: (0, tau), + spin: (-10, 10), + colorOverLife: ColorRamp( + const [Colors.pinkAccent, Colors.tealAccent, Colors.amberAccent], + ), + opacityOverLife: ParticleCurve(1, 0, curve: Curves.easeInExpo), + ), + SpriteParticleRenderer.fromImage(zap), + ); + case 'Streaks': + _perDragUpdate = 12; + final paint = Paint() + ..strokeWidth = 2 + ..strokeCap = ui.StrokeCap.round + ..blendMode = BlendMode.plus; + return _pooled( + ParticleEmitter( + maxParticles: 8192, + lifespan: (0.6, 1.4), + speed: (60, 200), + drag: 1.5, + size: (2, 4), + ), + CallbackParticleRenderer((canvas, particles) { + for (var i = 0; i < particles.length; i++) { + paint.color = Color.lerp( + Colors.limeAccent, + Colors.transparent, + particles.progressAt(i), + )!; + final x = particles.posX[i]; + final y = particles.posY[i]; + canvas.drawLine( + Offset(x, y), + Offset( + x - particles.velX[i] * 0.08, + y - particles.velY[i] * 0.08, + ), + paint, + ); + } + }), + ); + case 'Bubbles': + _perDragUpdate = 8; + return _pooled( + ParticleEmitter( + maxParticles: 4096, + lifespan: (0.8, 2), + shape: const CircleEmitterShape(8), + speed: (5, 40), + gravity: Vector2(0, -120), + drag: 1, + size: (3, 10), + colorOverLife: ColorRamp( + const [Colors.white, Colors.lightBlueAccent], + ), + opacityOverLife: ParticleCurve(0.7, 0), + ), + CircleParticleRenderer(), + ); + case 'Sparkles': + default: + _perDragUpdate = 20; + return _pooled( + ParticleEmitter( + maxParticles: 8192, + lifespan: (0.5, 2), + shape: const CircleEmitterShape(4), + speed: (10, 120), + drag: 1, + size: (2, 6), + scaleOverLife: ParticleCurve(1, 0, curve: Curves.easeOut), + colorOverLife: ColorRamp( + const [Colors.white, Colors.cyanAccent, Colors.purpleAccent], + ), + ), + CircleParticleRenderer(softness: 0.4, blendMode: BlendMode.plus), + ); + } + } } diff --git a/examples/lib/stories/rendering/rendering.dart b/examples/lib/stories/rendering/rendering.dart index 887b1ecd62e..718bc3a16fc 100644 --- a/examples/lib/stories/rendering/rendering.dart +++ b/examples/lib/stories/rendering/rendering.dart @@ -58,8 +58,11 @@ void addRenderingStories(Dashbook dashbook) { 'Particles (Interactive)', (context) => GameWidget( game: ParticlesInteractiveExample( - from: context.colorProperty('From color', Colors.pink), - to: context.colorProperty('To color', Colors.blue), + effect: context.listProperty( + 'Effect', + ParticlesInteractiveExample.effects.first, + ParticlesInteractiveExample.effects, + ), zoom: context.numberProperty('Zoom', 1), ), ), diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 134b3554c2c..836f1ae9c67 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -39,11 +39,9 @@ export 'src/components/mixins/ignore_events.dart'; export 'src/components/mixins/keyboard_handler.dart'; export 'src/components/mixins/notifier.dart'; export 'src/components/mixins/parent_is_a.dart'; -export 'src/components/mixins/single_child_particle.dart'; export 'src/components/mixins/snapshot.dart'; export 'src/components/nine_tile_box_component.dart'; export 'src/components/parallax_component.dart'; -export 'src/components/particle_system_component.dart'; export 'src/components/position_component.dart'; export 'src/components/raster_sprite_component.dart'; export 'src/components/scroll_text_box_component.dart'; diff --git a/packages/flame/lib/particles.dart b/packages/flame/lib/particles.dart index 6d280da3c26..af1971053ea 100644 --- a/packages/flame/lib/particles.dart +++ b/packages/flame/lib/particles.dart @@ -1,17 +1,14 @@ -export 'src/anchor.dart'; -export 'src/particles/accelerated_particle.dart'; -export 'src/particles/circle_particle.dart'; -export 'src/particles/component_particle.dart'; -export 'src/particles/composed_particle.dart'; -export 'src/particles/computed_particle.dart'; -export 'src/particles/curved_particle.dart'; -export 'src/particles/image_particle.dart'; -export 'src/particles/moving_particle.dart'; -export 'src/particles/paint_particle.dart'; -export 'src/particles/particle.dart'; -export 'src/particles/rotating_particle.dart'; -export 'src/particles/scaled_particle.dart'; -export 'src/particles/scaling_particle.dart'; -export 'src/particles/sprite_animation_particle.dart'; -export 'src/particles/sprite_particle.dart'; -export 'src/particles/translated_particle.dart'; +/// A data-oriented particle system: declarative `ParticleEmitter` presets, +/// struct-of-arrays simulation, and batched single-draw-call rendering. +library; + +export 'src/particles/circle_particle_renderer.dart'; +export 'src/particles/color_ramp.dart'; +export 'src/particles/emitter_shape.dart'; +export 'src/particles/particle_buffer.dart'; +export 'src/particles/particle_curve.dart'; +export 'src/particles/particle_emitter.dart'; +export 'src/particles/particle_emitter_component.dart'; +export 'src/particles/particle_range.dart'; +export 'src/particles/particle_renderer.dart'; +export 'src/particles/sprite_particle_renderer.dart'; diff --git a/packages/flame/lib/src/components/mixins/single_child_particle.dart b/packages/flame/lib/src/components/mixins/single_child_particle.dart deleted file mode 100644 index ac9544da6be..00000000000 --- a/packages/flame/lib/src/components/mixins/single_child_particle.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/particles/particle.dart'; - -/// Implements basic behavior for nesting [Particle] instances -/// into each other. -/// -/// ```dart -/// class BehaviorParticle extends Particle with SingleChildParticle { -/// Particle child; -/// -/// BehaviorParticle({ -/// required this.child -/// }); -/// -/// @override -/// update(double dt) { -/// // Will ensure that child [Particle] is properly updated -/// super.update(dt); -/// -/// // ... Custom behavior -/// } -/// } -/// ``` -mixin SingleChildParticle on Particle { - late Particle child; - - @override - void setLifespan(double lifespan) { - super.setLifespan(lifespan); - child.setLifespan(lifespan); - } - - @override - void render(Canvas canvas) { - child.render(canvas); - } - - @override - void update(double dt) { - super.update(dt); - child.update(dt); - } -} diff --git a/packages/flame/lib/src/components/particle_system_component.dart b/packages/flame/lib/src/components/particle_system_component.dart deleted file mode 100644 index f2c1ce3df16..00000000000 --- a/packages/flame/lib/src/components/particle_system_component.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/components.dart'; -import 'package:flame/particles.dart'; - -/// {@template particle_system_component} -/// A [PositionComponent] that renders a [Particle] at the designated -/// position, scaled to have the designated size and rotated to the specified -/// angle. -/// {@endtemplate} -class ParticleSystemComponent extends PositionComponent { - Particle? particle; - - /// {@macro particle_system_component} - ParticleSystemComponent({ - this.particle, - super.position, - super.size, - super.scale, - super.angle, - super.anchor, - super.priority, - super.key, - }); - - /// Returns progress of the child [Particle]. - /// - /// Could be used by external code if needed. - double get progress => particle?.progress ?? 0; - - /// Passes rendering chain down to the inset - /// [Particle] within this [Component]. - @override - void render(Canvas canvas) { - super.render(canvas); - particle?.render(canvas); - } - - /// Passes update chain to child [Particle]. - @override - void update(double dt) { - particle?.update(dt); - if (particle?.shouldRemove ?? false) { - removeFromParent(); - } - } -} diff --git a/packages/flame/lib/src/particles/accelerated_particle.dart b/packages/flame/lib/src/particles/accelerated_particle.dart deleted file mode 100644 index 58029e4566d..00000000000 --- a/packages/flame/lib/src/particles/accelerated_particle.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flame/extensions.dart'; -import 'package:flame/src/components/mixins/single_child_particle.dart'; -import 'package:flame/src/particles/curved_particle.dart'; -import 'package:flame/src/particles/particle.dart'; - -/// A particle that serves as a container for basic acceleration physics. -/// -/// [speed] is logical px per second. -/// -/// ```dart -/// AcceleratedParticle( -/// speed: Vector2(0, 100), // is 100 logical px/s down. -/// acceleration: Vector2(-40, 0) // will accelerate to the left at rate of 40 px/s -/// ) -/// ``` -class AcceleratedParticle extends CurvedParticle with SingleChildParticle { - @override - Particle child; - - final Vector2 acceleration; - Vector2 speed; - Vector2 position; - - AcceleratedParticle({ - required this.child, - Vector2? acceleration, - Vector2? speed, - Vector2? position, - super.lifespan, - }) : acceleration = acceleration ?? Vector2.zero(), - position = position ?? Vector2.zero(), - speed = speed ?? Vector2.zero(); - - @override - void render(Canvas canvas) { - canvas.save(); - canvas.translateVector(position); - super.render(canvas); - canvas.restore(); - } - - @override - void update(double dt) { - speed.addScaled(acceleration, dt); - position - ..addScaled(speed, dt) - ..addScaled(acceleration, -dt * dt * 0.5); - super.update(dt); - } -} diff --git a/packages/flame/lib/src/particles/circle_particle.dart b/packages/flame/lib/src/particles/circle_particle.dart deleted file mode 100644 index 0671031c2c1..00000000000 --- a/packages/flame/lib/src/particles/circle_particle.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/particles/particle.dart'; - -/// Plain circle with no other behaviors. -/// -/// Consider composing this with other [Particle]s to achieve needed effects. -class CircleParticle extends Particle { - final Paint paint; - final double radius; - - CircleParticle({ - required this.paint, - this.radius = 10.0, - super.lifespan, - }); - - @override - void render(Canvas canvas) { - canvas.drawCircle(Offset.zero, radius, paint); - } -} diff --git a/packages/flame/lib/src/particles/circle_particle_renderer.dart b/packages/flame/lib/src/particles/circle_particle_renderer.dart new file mode 100644 index 00000000000..148e4dc5659 --- /dev/null +++ b/packages/flame/lib/src/particles/circle_particle_renderer.dart @@ -0,0 +1,68 @@ +import 'dart:ui'; + +import 'package:flame/extensions.dart'; +import 'package:flame/src/particles/particle_renderer.dart'; + +/// Renders each particle as a circle, batched into a single draw call. +/// +/// A circle texture is rasterized once when the renderer loads; every +/// particle is then a transformed, tinted copy of it. The circle's color +/// comes from the emitter's `colorOverLife` ramp (white when unset). +class CircleParticleRenderer extends TextureParticleRenderer { + /// Creates a circle renderer. + /// + /// [softness] goes from 0 (a crisp edge) to 1 (fully faded from the + /// center), which suits smoke and glow, especially combined with + /// `blendMode: BlendMode.plus`. + CircleParticleRenderer({ + this.softness = 0, + super.blendMode, + super.paint, + }) : assert( + softness >= 0 && softness <= 1, + 'softness must be between 0 and 1', + ); + + /// How much the circle fades out towards its edge, from 0 (crisp) to 1 + /// (fully soft). + final double softness; + + static const int _textureSize = 64; + + Image? _texture; + + @override + Image? get texture => _texture; + + @override + Rect get srcRect => + Rect.fromLTWH(0, 0, _textureSize.toDouble(), _textureSize.toDouble()); + + @override + Future onLoad() async { + if (_texture != null) { + return; + } + const white = Color(0xffffffff); + const transparent = Color(0x00ffffff); + const radius = _textureSize / 2; + const center = Offset(radius, radius); + final paint = Paint(); + if (softness > 0) { + paint.shader = Gradient.radial( + center, + radius, + const [white, white, transparent], + [0, (1 - softness).clamp(0.0, 0.99), 1], + ); + } else { + paint.color = white; + } + final recorder = PictureRecorder(); + Canvas(recorder).drawCircle(center, radius, paint); + _texture = await recorder.endRecording().toImageSafe( + _textureSize, + _textureSize, + ); + } +} diff --git a/packages/flame/lib/src/particles/color_ramp.dart b/packages/flame/lib/src/particles/color_ramp.dart new file mode 100644 index 00000000000..ace910ce88f --- /dev/null +++ b/packages/flame/lib/src/particles/color_ramp.dart @@ -0,0 +1,62 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +/// A color gradient over a particle's lifetime. +/// +/// The gradient is baked into a lookup table when constructed, so evaluating +/// it per particle per frame is a single array read. +class ColorRamp { + /// Creates a ramp that interpolates through [colors] over the particle's + /// lifetime. + /// + /// [stops] optionally positions each color on the 0...1 life progress + /// axis; when omitted the colors are spaced evenly. + ColorRamp(List colors, {List? stops}) + : assert(colors.isNotEmpty, 'colors must not be empty'), + assert( + stops == null || stops.length == colors.length, + 'stops must have the same length as colors', + ) { + if (colors.length == 1) { + _lut.fillRange(0, _resolution, colors.first.toARGB32()); + return; + } + final positions = + stops ?? List.generate(colors.length, (i) => i / (colors.length - 1)); + var segment = 0; + for (var i = 0; i < _resolution; i++) { + final t = i / (_resolution - 1); + while (segment < colors.length - 2 && t > positions[segment + 1]) { + segment++; + } + final start = positions[segment]; + final end = positions[segment + 1]; + final local = end > start ? ((t - start) / (end - start)) : 1.0; + final color = Color.lerp( + colors[segment], + colors[segment + 1], + local.clamp(0.0, 1.0), + )!; + _lut[i] = color.toARGB32(); + } + } + + /// A ramp that keeps the same [color] over the whole lifetime. + ColorRamp.solid(Color color) : this([color]); + + static const int _resolution = 256; + + final Int32List _lut = Int32List(_resolution); + + /// The 32-bit ARGB value at life progress [t], which is clamped to 0...1. + /// + /// The value is read from an [Int32List], so it may be negative when + /// interpreted as a signed integer; mask with `0xffffffff` before passing + /// it to a [Color] constructor. + int valueAt(double t) { + return _lut[(t.clamp(0.0, 1.0) * (_resolution - 1)).toInt()]; + } + + /// The [Color] at life progress [t], which is clamped to 0...1. + Color colorAt(double t) => Color(valueAt(t) & 0xffffffff); +} diff --git a/packages/flame/lib/src/particles/component_particle.dart b/packages/flame/lib/src/particles/component_particle.dart deleted file mode 100644 index 00f3fd34743..00000000000 --- a/packages/flame/lib/src/particles/component_particle.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/components/core/component.dart'; -import 'package:flame/src/extensions/vector2.dart'; -import 'package:flame/src/particles/particle.dart'; - -class ComponentParticle extends Particle { - final Component component; - final Vector2? size; - final Paint? overridePaint; - - ComponentParticle({ - required this.component, - this.size, - this.overridePaint, - super.lifespan, - }); - - @override - void render(Canvas canvas) { - component.render(canvas); - } - - @override - void update(double dt) { - super.update(dt); - component.update(dt); - } -} diff --git a/packages/flame/lib/src/particles/composed_particle.dart b/packages/flame/lib/src/particles/composed_particle.dart deleted file mode 100644 index c104ae442d3..00000000000 --- a/packages/flame/lib/src/particles/composed_particle.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/particles/particle.dart'; - -/// A single [Particle] which manages multiple children -/// by proxying all lifecycle hooks. -/// -/// [applyLifespanToChildren] if true, then [children] will have the same -/// lifespan as parent [ComposedParticle] -class ComposedParticle extends Particle { - final List children; - final bool applyLifespanToChildren; - - ComposedParticle({ - required this.children, - super.lifespan, - this.applyLifespanToChildren = true, - }); - - @override - void setLifespan(double lifespan) { - super.setLifespan(lifespan); - - if (applyLifespanToChildren) { - for (final child in children) { - child.setLifespan(lifespan); - } - } - } - - @override - void render(Canvas canvas) { - for (final child in children) { - child.render(canvas); - } - } - - @override - void update(double dt) { - super.update(dt); - - children.removeWhere((particle) => particle.shouldRemove); - - for (final child in children) { - child.update(dt); - } - } -} diff --git a/packages/flame/lib/src/particles/computed_particle.dart b/packages/flame/lib/src/particles/computed_particle.dart deleted file mode 100644 index b463c8fcb5f..00000000000 --- a/packages/flame/lib/src/particles/computed_particle.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/particles/particle.dart'; - -/// A function which should render desired contents -/// onto a given canvas. External state needed for -/// rendering should be stored elsewhere, so that this delegate could use it -typedef ParticleRenderDelegate = void Function(Canvas c, Particle particle); - -/// An abstract [Particle] container which delegates rendering outside -/// Allows to implement very interesting scenarios from scratch. -class ComputedParticle extends Particle { - // A delegate function which will be called - // to render particle on each frame - ParticleRenderDelegate renderer; - - ComputedParticle({ - required this.renderer, - super.lifespan, - }); - - @override - void render(Canvas canvas) { - renderer(canvas, this); - } -} diff --git a/packages/flame/lib/src/particles/curved_particle.dart b/packages/flame/lib/src/particles/curved_particle.dart deleted file mode 100644 index f3573b0fa9b..00000000000 --- a/packages/flame/lib/src/particles/curved_particle.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:flame/src/particles/particle.dart'; -import 'package:flutter/animation.dart'; - -/// A [Particle] which applies certain [Curve] for -/// easing or other purposes to its [progress] getter. -class CurvedParticle extends Particle { - final Curve curve; - - CurvedParticle({ - this.curve = Curves.linear, - super.lifespan, - }); - - @override - double get progress => curve.transform(super.progress); -} diff --git a/packages/flame/lib/src/particles/emitter_shape.dart b/packages/flame/lib/src/particles/emitter_shape.dart new file mode 100644 index 00000000000..9c9fab4fb6c --- /dev/null +++ b/packages/flame/lib/src/particles/emitter_shape.dart @@ -0,0 +1,66 @@ +import 'dart:math'; + +import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/constants.dart'; + +/// The area, relative to the emitter's position, in which new particles +/// spawn. +/// +/// Subclass this to spawn particles in custom patterns. +abstract class EmitterShape { + /// Allows subclasses to have const constructors. + const EmitterShape(); + + /// Writes a spawn offset, relative to the emitter, into [out]. + void samplePosition(Random random, Vector2 out); +} + +/// Spawns every particle exactly at the emitter's position. +class PointEmitterShape extends EmitterShape { + /// Spawns every particle exactly at the emitter's position. + const PointEmitterShape(); + + @override + void samplePosition(Random random, Vector2 out) => out.setZero(); +} + +/// Spawns particles uniformly inside a circle, or on its edge when +/// [edgeOnly] is true. +class CircleEmitterShape extends EmitterShape { + /// Spawns particles inside (or on the edge of) a circle with the given + /// [radius]. + const CircleEmitterShape(this.radius, {this.edgeOnly = false}); + + /// The radius of the spawn circle, in local units. + final double radius; + + /// When true, particles spawn on the circle's edge instead of inside it. + final bool edgeOnly; + + @override + void samplePosition(Random random, Vector2 out) { + final angle = random.nextDouble() * tau; + final distance = edgeOnly ? radius : radius * sqrt(random.nextDouble()); + out.setValues(cos(angle) * distance, sin(angle) * distance); + } +} + +/// Spawns particles uniformly inside a rectangle centered on the emitter. +class RectangleEmitterShape extends EmitterShape { + /// Spawns particles inside a centered rectangle of [width] by [height]. + const RectangleEmitterShape(this.width, this.height); + + /// The width of the spawn rectangle, in local units. + final double width; + + /// The height of the spawn rectangle, in local units. + final double height; + + @override + void samplePosition(Random random, Vector2 out) { + out.setValues( + (random.nextDouble() - 0.5) * width, + (random.nextDouble() - 0.5) * height, + ); + } +} diff --git a/packages/flame/lib/src/particles/image_particle.dart b/packages/flame/lib/src/particles/image_particle.dart deleted file mode 100644 index 422488f69c0..00000000000 --- a/packages/flame/lib/src/particles/image_particle.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/extensions/vector2.dart'; -import 'package:flame/src/particles/particle.dart'; - -/// A [Particle] which renders given [Image] on a [Canvas] image is centered. -/// If any other behavior is needed, consider using ComputedParticle. -class ImageParticle extends Particle { - /// dart.ui [Image] to draw - Image image; - Paint paint; - - late Rect src; - late Rect dest; - - ImageParticle({ - required this.image, - Paint? paint, - Vector2? size, - super.lifespan, - }) : paint = paint ?? Paint() { - final srcWidth = image.width.toDouble(); - final srcHeight = image.height.toDouble(); - final destWidth = size?.x ?? srcWidth; - final destHeight = size?.y ?? srcHeight; - - src = Rect.fromLTWH(0, 0, srcWidth, srcHeight); - dest = Rect.fromLTWH( - -destWidth / 2, - -destHeight / 2, - destWidth, - destHeight, - ); - } - - @override - void render(Canvas canvas) { - canvas.drawImageRect(image, src, dest, paint); - } -} diff --git a/packages/flame/lib/src/particles/moving_particle.dart b/packages/flame/lib/src/particles/moving_particle.dart deleted file mode 100644 index 5a55ceb9427..00000000000 --- a/packages/flame/lib/src/particles/moving_particle.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flame/extensions.dart'; -import 'package:flame/src/components/mixins/single_child_particle.dart'; -import 'package:flame/src/particles/curved_particle.dart'; -import 'package:flame/src/particles/particle.dart'; - -/// Statically move given child [Particle] by given [Vector2]. -/// -/// If you're looking to move the child, consider the [MovingParticle]. -class MovingParticle extends CurvedParticle with SingleChildParticle { - @override - Particle child; - - final Vector2 from; - final Vector2 to; - - MovingParticle({ - required this.child, - required this.to, - Vector2? from, - super.lifespan, - super.curve, - }) : from = from ?? Vector2.zero(); - - /// Used to avoid creating new [Vector2] objects in [update]. - static final _tmpVector = Vector2.zero(); - - @override - void render(Canvas canvas) { - canvas.save(); - final current = _tmpVector - ..setFrom(from) - ..lerp(to, progress); - canvas.translateVector(current); - super.render(canvas); - canvas.restore(); - } -} diff --git a/packages/flame/lib/src/particles/paint_particle.dart b/packages/flame/lib/src/particles/paint_particle.dart deleted file mode 100644 index ca57466f64a..00000000000 --- a/packages/flame/lib/src/particles/paint_particle.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/components/mixins/single_child_particle.dart'; -import 'package:flame/src/particles/curved_particle.dart'; -import 'package:flame/src/particles/particle.dart'; - -/// A particle which renders its child with certain [Paint] -/// Could be used for applying composite effects. -/// Be aware that any composite operation is relatively expensive, as involves -/// copying portions of GPU memory. The less pixels copied, the faster it'll be. -class PaintParticle extends CurvedParticle with SingleChildParticle { - @override - Particle child; - - final Paint paint; - - /// Defines Canvas layer bounds - /// for applying this particle composite effect. - /// Any child content outside this bounds will be clipped. - final Rect bounds; - - PaintParticle({ - required this.child, - required this.paint, - - // Reasonably large rect for most particles - this.bounds = const Rect.fromLTRB(-50, -50, 50, 50), - super.lifespan, - }); - - @override - void render(Canvas canvas) { - canvas.saveLayer(bounds, paint); - super.render(canvas); - canvas.restore(); - } -} diff --git a/packages/flame/lib/src/particles/particle.dart b/packages/flame/lib/src/particles/particle.dart deleted file mode 100644 index 95687e2d2d2..00000000000 --- a/packages/flame/lib/src/particles/particle.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'dart:math'; - -import 'package:flame/extensions.dart'; -import 'package:flame/src/particles/accelerated_particle.dart'; -import 'package:flame/src/particles/composed_particle.dart'; -import 'package:flame/src/particles/moving_particle.dart'; -import 'package:flame/src/particles/rotating_particle.dart'; -import 'package:flame/src/particles/scaled_particle.dart'; -import 'package:flame/src/particles/scaling_particle.dart'; -import 'package:flame/src/particles/translated_particle.dart'; -import 'package:flame/src/timer.dart'; -import 'package:flutter/animation.dart'; - -/// A function which returns a [Particle] when called. -typedef ParticleGenerator = Particle Function(int); - -/// Base class implementing common behavior for all the particles. -/// -/// Intention is to follow the same "Extreme Composability" style as seen across -/// the whole Flutter framework. Each type of particle implements some -/// particular behavior which then could be nested and combined to create -/// the experience you are looking for. -abstract class Particle { - /// Generates a given amount of particles and then combining them into one - /// single [ComposedParticle]. - /// - /// Useful for procedural particle generation. - static Particle generate({ - required ParticleGenerator generator, - int count = 10, - double? lifespan, - bool applyLifespanToChildren = true, - }) { - return ComposedParticle( - lifespan: lifespan, - applyLifespanToChildren: applyLifespanToChildren, - children: List.generate(count, generator), - ); - } - - /// Internal timer defining how long this [Particle] will live. - /// - /// [Particle] will be marked for removal when this timer is over. - Timer? _timer; - - /// Stores desired lifespan of the particle in seconds. - late double _lifespan; - - /// Will be set to true by [update] when this [Particle] reaches the end of - /// its lifespan. - bool _shouldBeRemoved = false; - - /// Construct a new [Particle]. - /// - /// The [lifespan] is how long this [Particle] will live in seconds, with - /// microsecond precision. - Particle({ - double? lifespan, - }) { - setLifespan(lifespan ?? 0.5); - } - - /// Getter for the current lifespan of this [Particle]. - double get lifespan => _lifespan; - - /// This method will return true as soon as the particle reaches the end of - /// its lifespan. - /// - /// It will then be ready to be removed by a wrapping container. - bool get shouldRemove => _shouldBeRemoved; - - /// Getter which should be used by subclasses to get overall progress. - /// - /// Also allows to substitute progress with other values, for example adding - /// easing as in CurvedParticle. - double get progress => _timer?.progress ?? 0.0; - - /// Should render this [Particle] to given [Canvas]. - /// - /// Default behavior is empty, so that it's not required to override this in - /// a [Particle] that renders nothing and serve as a behavior container. - void render(Canvas canvas) {} - - /// Updates the [_timer] of this [Particle]. - void update(double dt) { - _timer?.update(dt); - } - - /// A control method allowing a parent of this [Particle] to pass down it's - /// lifespan. - /// - /// Allows to only specify desired lifespan once, at the very top of the - /// [Particle] tree which then will be propagated down using this method. - /// - /// See `SingleChildParticle` or [ComposedParticle] for details. - void setLifespan(double lifespan) { - // TODO(wolfenrain): Maybe make it into a setter/getter? - _lifespan = lifespan; - _timer?.stop(); - _timer = Timer(lifespan, onTick: () => _shouldBeRemoved = true)..start(); - } - - /// Wraps this particle with a [TranslatedParticle]. - /// - /// Statically repositioning it for the time of the lifespan. - Particle translated(Vector2 offset) { - return TranslatedParticle( - offset: offset, - child: this, - lifespan: _lifespan, - ); - } - - /// Wraps this particle with a [MovingParticle]. - /// - /// Allowing it to move from one [Vector2] to another one. - Particle moving({ - required Vector2 to, - Vector2? from, - Curve curve = Curves.linear, - }) { - return MovingParticle( - from: from ?? Vector2.zero(), - to: to, - curve: curve, - child: this, - lifespan: _lifespan, - ); - } - - /// Wraps this particle with a [AcceleratedParticle]. - /// - /// Allowing to specify desired position speed and acceleration and leave - /// the basic physics do the rest. - Particle accelerated({ - required Vector2 acceleration, - Vector2? position, - Vector2? speed, - }) { - return AcceleratedParticle( - position: position ?? Vector2.zero(), - speed: speed ?? Vector2.zero(), - acceleration: acceleration, - child: this, - lifespan: _lifespan, - ); - } - - /// Rotates this particle to a fixed angle in radians using the - /// [RotatingParticle]. - Particle rotated(double angle) { - return RotatingParticle( - child: this, - lifespan: _lifespan, - from: angle, - to: angle, - ); - } - - /// Rotates this particle from a given angle to another one in radians - /// using [RotatingParticle]. - Particle rotating({ - double from = 0, - double to = pi, - }) { - return RotatingParticle( - child: this, - lifespan: _lifespan, - from: from, - to: to, - ); - } - - /// Wraps this particle with a [ScaledParticle]. - /// - /// Allows for changing the size of this particle and/or its children. - Particle scaled(double scale) { - return ScaledParticle(scale: scale, child: this, lifespan: _lifespan); - } - - /// Wraps this particle with a [ScalingParticle]. - /// - /// Allows for changing the size of this particle and/or its children. - ScalingParticle scaling({double to = 0, Curve curve = Curves.linear}) { - return ScalingParticle( - to: to, - child: this, - lifespan: _lifespan, - curve: curve, - ); - } -} diff --git a/packages/flame/lib/src/particles/particle_buffer.dart b/packages/flame/lib/src/particles/particle_buffer.dart new file mode 100644 index 00000000000..5d5965498f9 --- /dev/null +++ b/packages/flame/lib/src/particles/particle_buffer.dart @@ -0,0 +1,107 @@ +import 'dart:typed_data'; + +/// Struct-of-arrays storage for the live particles of an emitter. +/// +/// All per-particle state lives in preallocated typed-data lists, so a +/// running particle system performs no per-particle allocations and the +/// simulation loop is a tight scan over contiguous memory. +/// +/// The particles at indices `0` to `length - 1` are alive. Removing a +/// particle swaps the last live particle into its slot, so iteration while +/// removing must not advance the index after a removal. +class ParticleBuffer { + /// Creates a buffer with room for [capacity] simultaneous particles. + ParticleBuffer(this.capacity) + : assert(capacity > 0, 'capacity must be positive'), + posX = Float32List(capacity), + posY = Float32List(capacity), + velX = Float32List(capacity), + velY = Float32List(capacity), + age = Float32List(capacity), + invLifespan = Float32List(capacity), + baseSize = Float32List(capacity), + size = Float32List(capacity), + rotation = Float32List(capacity), + spin = Float32List(capacity), + color = Int32List(capacity); + + /// The maximum number of simultaneous particles. + final int capacity; + + /// The x position, in the emitter's local coordinate system. + final Float32List posX; + + /// The y position, in the emitter's local coordinate system. + final Float32List posY; + + /// The x velocity, in local units per second. + final Float32List velX; + + /// The y velocity, in local units per second. + final Float32List velY; + + /// Time in seconds since the particle spawned. + final Float32List age; + + /// The reciprocal of the particle's lifespan; `age[i] * invLifespan[i]` + /// is the life progress from 0 (spawn) to 1 (death). + final Float32List invLifespan; + + /// The size the particle had when it spawned, in local units. + final Float32List baseSize; + + /// The current rendered size (width and height) in local units. + final Float32List size; + + /// The current rotation in radians. + final Float32List rotation; + + /// The angular velocity in radians per second. + final Float32List spin; + + /// The current color as a 32-bit ARGB value. + final Int32List color; + + int _length = 0; + + /// The number of currently live particles. + int get length => _length; + + /// Whether the buffer has reached [capacity]. + bool get isFull => _length == capacity; + + /// The life progress of the particle at [index], from 0 to 1. + double progressAt(int index) => age[index] * invLifespan[index]; + + /// Activates one particle slot and returns its index, or -1 when the + /// buffer is full. The caller is responsible for initializing every field + /// at the returned index. + int spawn() { + if (_length == capacity) { + return -1; + } + return _length++; + } + + /// Removes the particle at [index] by swapping the last live particle + /// into its slot. + void removeAt(int index) { + final last = --_length; + if (index != last) { + posX[index] = posX[last]; + posY[index] = posY[last]; + velX[index] = velX[last]; + velY[index] = velY[last]; + age[index] = age[last]; + invLifespan[index] = invLifespan[last]; + baseSize[index] = baseSize[last]; + size[index] = size[last]; + rotation[index] = rotation[last]; + spin[index] = spin[last]; + color[index] = color[last]; + } + } + + /// Removes all live particles. + void clear() => _length = 0; +} diff --git a/packages/flame/lib/src/particles/particle_curve.dart b/packages/flame/lib/src/particles/particle_curve.dart new file mode 100644 index 00000000000..7128b66ffac --- /dev/null +++ b/packages/flame/lib/src/particles/particle_curve.dart @@ -0,0 +1,43 @@ +import 'dart:typed_data'; + +import 'package:flutter/animation.dart'; + +/// A scalar value that changes over a particle's lifetime. +/// +/// The curve is baked into a small lookup table when constructed, so +/// evaluating it for thousands of particles per frame costs only an array +/// read and one interpolation, no matter how expensive the source function +/// is. +class ParticleCurve { + /// Interpolates from [from] at spawn to [to] at death, optionally shaped + /// by an animation [curve] (for example [Curves.easeOut]). + ParticleCurve(double from, double to, {Curve curve = Curves.linear}) + : this.custom((t) => from + (to - from) * curve.transform(t)); + + /// Keeps the same [value] over the whole lifetime. + ParticleCurve.constant(double value) : this.custom((_) => value); + + /// Bakes an arbitrary function of the life progress `t`, where `t` goes + /// from 0 at spawn to 1 at death. + ParticleCurve.custom(double Function(double t) f) + : _samples = Float32List(_resolution + 1) { + for (var i = 0; i <= _resolution; i++) { + _samples[i] = f(i / _resolution); + } + } + + static const int _resolution = 64; + + final Float32List _samples; + + /// Evaluates the curve at life progress [t], which is clamped to 0...1. + double transform(double t) { + final scaled = t.clamp(0.0, 1.0) * _resolution; + final index = scaled.floor(); + if (index >= _resolution) { + return _samples[_resolution]; + } + final fraction = scaled - index; + return _samples[index] * (1 - fraction) + _samples[index + 1] * fraction; + } +} diff --git a/packages/flame/lib/src/particles/particle_emitter.dart b/packages/flame/lib/src/particles/particle_emitter.dart new file mode 100644 index 00000000000..71c8ef2213a --- /dev/null +++ b/packages/flame/lib/src/particles/particle_emitter.dart @@ -0,0 +1,160 @@ +import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/constants.dart'; +import 'package:flame/src/particles/color_ramp.dart'; +import 'package:flame/src/particles/emitter_shape.dart'; +import 'package:flame/src/particles/particle_curve.dart'; +import 'package:flame/src/particles/particle_range.dart'; + +/// A one-off release of [count] particles, [time] seconds after emission +/// starts. +class EmitterBurst { + /// Releases [count] particles [time] seconds after emission starts. + const EmitterBurst(this.time, this.count) + : assert(time >= 0, 'time must not be negative'), + assert(count > 0, 'count must be positive'); + + /// Seconds after emission start at which the burst fires. + final double time; + + /// The number of particles released by the burst. + final int count; +} + +/// A declarative description of how particles are spawned and how they +/// behave over their lifetime. +/// +/// A [ParticleEmitter] is a reusable preset: the same instance can be shared +/// by any number of emitter components. All distances are in the emitter +/// component's local units, all angles in radians, and all times in seconds. +class ParticleEmitter { + /// Creates an emitter preset. + /// + /// Emission is continuous through [rate] (particles per second) and/or + /// discrete through [bursts]. An emitter with only bursts finishes after + /// the last burst has fired; an emitter with a positive [rate] keeps + /// emitting until [duration] has passed, or forever when [duration] is + /// null. With [loop] enabled the emission timeline restarts after + /// [duration], which must then be set. + ParticleEmitter({ + this.maxParticles = 1024, + this.rate = 0, + this.bursts = const [], + this.duration, + this.loop = false, + this.lifespan = (1, 1), + this.shape = const PointEmitterShape(), + this.speed = (0, 0), + this.direction = 0, + this.spread = tau, + Vector2? gravity, + this.drag = 0, + this.size = (8, 8), + this.rotation = (0, 0), + this.spin = (0, 0), + this.rotateToVelocity = false, + this.scaleOverLife, + this.opacityOverLife, + this.colorOverLife, + }) : assert(maxParticles > 0, 'maxParticles must be positive'), + assert(rate >= 0, 'rate must not be negative'), + assert(spread >= 0, 'spread must not be negative'), + assert(drag >= 0, 'drag must not be negative'), + assert(duration == null || duration > 0, 'duration must be positive'), + assert( + !loop || duration != null, + 'loop requires duration to be set', + ), + gravity = gravity ?? Vector2.zero(); + + /// The maximum number of simultaneous particles. Storage for all of them + /// is allocated up front; spawns beyond this limit are dropped until + /// older particles die. + final int maxParticles; + + /// Particles spawned per second, continuously. Zero means burst-only. + final double rate; + + /// One-off releases of particles at fixed times on the emission timeline. + final List bursts; + + /// How long emission lasts. When null, a [rate]-based emitter emits + /// forever, and a burst-only emitter finishes after its last burst. + final double? duration; + + /// Whether the emission timeline restarts after [duration]. + final bool loop; + + /// The lifespan of each particle, in seconds. + final ParticleRange lifespan; + + /// The area in which particles spawn, relative to the emitter's position. + final EmitterShape shape; + + /// The initial speed of each particle, in local units per second. + final ParticleRange speed; + + /// The center of the emission direction, in radians. Zero points along + /// the positive x-axis; `-tau / 4` points up. + final double direction; + + /// The angle, in radians, over which emission directions are spread, + /// centered on [direction]. The default `tau` emits in all directions. + final double spread; + + /// A constant acceleration applied to every particle, in local units per + /// second squared. + final Vector2 gravity; + + /// Velocity damping: each second, velocity is scaled by roughly + /// `1 / (1 + drag)`. Zero means no damping. + final double drag; + + /// The rendered size (width and height) of each particle when it spawns, + /// in local units. + final ParticleRange size; + + /// The initial rotation of each particle, in radians. Ignored when + /// [rotateToVelocity] is true. + final ParticleRange rotation; + + /// The angular velocity of each particle, in radians per second. Ignored + /// when [rotateToVelocity] is true. + final ParticleRange spin; + + /// When true, each particle is rotated to point along its velocity, + /// which suits directional particles such as sparks or arrows. + final bool rotateToVelocity; + + /// How the particle's size scales over its lifetime, as a multiplier of + /// its spawn [size]. When null the size stays constant. + final ParticleCurve? scaleOverLife; + + /// How the particle's opacity changes over its lifetime, as a multiplier + /// of the alpha from [colorOverLife] (or of full opacity when + /// [colorOverLife] is null). Values are clamped to 0...1. + final ParticleCurve? opacityOverLife; + + /// The particle's color over its lifetime. When null, particles are + /// white; with texture renderers this leaves the texture untinted. + final ColorRamp? colorOverLife; + + /// The time at which emission naturally ends: [duration] when set, + /// infinity for an endless [rate]-based emitter, and the time of the last + /// burst for a burst-only emitter. + double get emissionEndTime { + final duration = this.duration; + if (duration != null) { + return duration; + } + if (rate > 0) { + return double.infinity; + } + var end = 0.0; + for (final burst in bursts) { + if (burst.time > end) { + end = burst.time; + } + } + return end; + } +} diff --git a/packages/flame/lib/src/particles/particle_emitter_component.dart b/packages/flame/lib/src/particles/particle_emitter_component.dart new file mode 100644 index 00000000000..887898a77ae --- /dev/null +++ b/packages/flame/lib/src/particles/particle_emitter_component.dart @@ -0,0 +1,295 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/src/particles/particle_buffer.dart'; +import 'package:flame/src/particles/particle_emitter.dart'; +import 'package:flame/src/particles/particle_range.dart'; +import 'package:flame/src/particles/particle_renderer.dart'; + +/// A component that spawns, simulates, and renders particles described by a +/// [ParticleEmitter]. +/// +/// The simulation is data-oriented: all particle state lives in a +/// preallocated [ParticleBuffer], and the built-in texture renderers draw +/// every particle in a single batched canvas call, so tens of thousands of +/// particles are cheap. +/// +/// ```dart +/// world.add( +/// ParticleEmitterComponent( +/// position: Vector2(200, 100), +/// emitter: ParticleEmitter( +/// bursts: [EmitterBurst(0, 200)], +/// lifespan: (0.4, 1.2), +/// speed: (50, 150), +/// gravity: Vector2(0, 200), +/// scaleOverLife: ParticleCurve(1, 0), +/// colorOverLife: ColorRamp([Colors.yellow, Colors.red]), +/// ), +/// renderer: CircleParticleRenderer(), +/// ), +/// ); +/// ``` +/// +/// Particles simulate in the component's local coordinate system, so moving +/// the component moves all live particles with it. Set [worldSpace] to true +/// to leave already-spawned particles behind instead, which is what trails +/// and exhaust effects want. +class ParticleEmitterComponent extends PositionComponent { + /// Creates a particle emitter component. + /// + /// [emitter] describes what to spawn and how it behaves; [renderer] + /// describes how it is drawn. When [removeOnFinish] is true (the + /// default), the component removes itself once emission has naturally + /// finished and the last particle has died; endless emitters are never + /// removed automatically. Pass a seeded [random] for deterministic + /// behavior. + ParticleEmitterComponent({ + required this.emitter, + required this.renderer, + this.removeOnFinish = true, + this.worldSpace = false, + bool emitting = true, + Random? random, + super.position, + super.size, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + super.key, + }) : _emitting = emitting, + random = random ?? Random(), + _buffer = ParticleBuffer(emitter.maxParticles), + _burstFired = List.filled(emitter.bursts.length, false); + + /// The description of what to spawn and how particles behave. + final ParticleEmitter emitter; + + /// The renderer that draws the live particles. + final ParticleRenderer renderer; + + /// Whether the component removes itself once emission has finished and no + /// particles are alive. + bool removeOnFinish; + + /// When true, live particles keep their world position while the + /// component moves, so a moving emitter leaves a trail behind. + /// + /// Only translation is compensated: ancestors of the component (and the + /// component itself) should not be rotated or scaled. + final bool worldSpace; + + /// The random source used for all sampling; seed it for determinism. + final Random random; + + final ParticleBuffer _buffer; + final List _burstFired; + final Vector2 _sampledPosition = Vector2.zero(); + Vector2? _lastAbsolutePosition; + + bool _emitting; + bool _completed = false; + double _clock = 0; + double _emitDebt = 0; + + /// The live particles. Exposed for custom renderers, custom behaviors, + /// and tests; treat it as read-only unless you know what you are doing. + ParticleBuffer get particles => _buffer; + + /// The number of currently live particles. + int get particleCount => _buffer.length; + + /// Whether the emission timeline is running. + bool get emitting => _emitting; + + /// Whether emission has naturally finished and no particles are alive. + bool get isFinished => _completed && _buffer.length == 0; + + @override + FutureOr onLoad() => renderer.onLoad(); + + /// Starts (or resumes) emission; if emission had finished, the timeline + /// restarts from the beginning. + void start() { + if (_completed) { + _clock = 0; + _emitDebt = 0; + _burstFired.fillRange(0, _burstFired.length, false); + _completed = false; + } + _emitting = true; + } + + /// Pauses emission; live particles keep simulating. + void stop() => _emitting = false; + + /// Immediately spawns [count] particles, independently of the emission + /// timeline. Spawns beyond the emitter's `maxParticles` are dropped. + void emit(int count) => _spawnMany(count); + + /// Removes all live particles. + void clearParticles() => _buffer.clear(); + + @override + void update(double dt) { + super.update(dt); + if (worldSpace) { + _compensateMovement(); + } + if (_emitting && !_completed) { + _advanceEmission(dt); + } + _simulate(dt); + if (removeOnFinish && isFinished) { + removeFromParent(); + } + } + + @override + void render(Canvas canvas) { + super.render(canvas); + renderer.render(canvas, _buffer); + } + + void _compensateMovement() { + final current = absolutePosition; + final last = _lastAbsolutePosition; + if (last == null) { + _lastAbsolutePosition = current.clone(); + return; + } + final dx = current.x - last.x; + final dy = current.y - last.y; + if (dx != 0 || dy != 0) { + final buffer = _buffer; + for (var i = 0; i < buffer.length; i++) { + buffer.posX[i] -= dx; + buffer.posY[i] -= dy; + } + } + last.setFrom(current); + } + + void _advanceEmission(double dt) { + final end = emitter.emissionEndTime; + final previous = _clock; + _clock += dt; + if (emitter.rate > 0) { + final activeTime = min(_clock, end) - previous; + if (activeTime > 0) { + _emitDebt += emitter.rate * activeTime; + final count = _emitDebt.floor(); + _emitDebt -= count; + _spawnMany(count); + } + } + final bursts = emitter.bursts; + for (var i = 0; i < bursts.length; i++) { + if (!_burstFired[i] && _clock >= bursts[i].time) { + _burstFired[i] = true; + _spawnMany(bursts[i].count); + } + } + if (_clock >= end) { + if (emitter.loop) { + // duration is asserted to be set and positive when looping. + _clock = _clock % end; + _burstFired.fillRange(0, _burstFired.length, false); + } else { + _completed = true; + } + } + } + + void _spawnMany(int count) { + for (var i = 0; i < count; i++) { + if (!_spawnOne()) { + break; + } + } + } + + bool _spawnOne() { + final buffer = _buffer; + final index = buffer.spawn(); + if (index < 0) { + return false; + } + emitter.shape.samplePosition(random, _sampledPosition); + buffer.posX[index] = _sampledPosition.x; + buffer.posY[index] = _sampledPosition.y; + final direction = + emitter.direction + (random.nextDouble() - 0.5) * emitter.spread; + final speed = emitter.speed.sample(random); + buffer.velX[index] = cos(direction) * speed; + buffer.velY[index] = sin(direction) * speed; + final lifespan = max(emitter.lifespan.sample(random), 1e-4); + buffer.age[index] = 0; + buffer.invLifespan[index] = 1 / lifespan; + final size = emitter.size.sample(random); + buffer.baseSize[index] = size; + final scaleCurve = emitter.scaleOverLife; + buffer.size[index] = scaleCurve == null + ? size + : size * scaleCurve.transform(0); + buffer.rotation[index] = emitter.rotateToVelocity + ? direction + : emitter.rotation.sample(random); + buffer.spin[index] = emitter.spin.sample(random); + buffer.color[index] = _colorAt(0); + return true; + } + + void _simulate(double dt) { + final buffer = _buffer; + final accelerationX = emitter.gravity.x; + final accelerationY = emitter.gravity.y; + final damping = emitter.drag > 0 ? 1 / (1 + emitter.drag * dt) : 1.0; + final scaleCurve = emitter.scaleOverLife; + final tintsOverLife = + emitter.colorOverLife != null || emitter.opacityOverLife != null; + var i = 0; + while (i < buffer.length) { + final age = buffer.age[i] + dt; + final progress = age * buffer.invLifespan[i]; + if (progress >= 1) { + buffer.removeAt(i); + continue; + } + buffer.age[i] = age; + final velX = (buffer.velX[i] + accelerationX * dt) * damping; + final velY = (buffer.velY[i] + accelerationY * dt) * damping; + buffer.velX[i] = velX; + buffer.velY[i] = velY; + buffer.posX[i] += velX * dt; + buffer.posY[i] += velY * dt; + if (emitter.rotateToVelocity) { + buffer.rotation[i] = atan2(velY, velX); + } else { + buffer.rotation[i] += buffer.spin[i] * dt; + } + if (scaleCurve != null) { + buffer.size[i] = buffer.baseSize[i] * scaleCurve.transform(progress); + } + if (tintsOverLife) { + buffer.color[i] = _colorAt(progress); + } + i++; + } + } + + int _colorAt(double progress) { + var color = emitter.colorOverLife?.valueAt(progress) ?? 0xffffffff; + final opacityCurve = emitter.opacityOverLife; + if (opacityCurve != null) { + final opacity = opacityCurve.transform(progress).clamp(0.0, 1.0); + final alpha = (((color >> 24) & 0xff) * opacity).round(); + color = (alpha << 24) | (color & 0x00ffffff); + } + return color; + } +} diff --git a/packages/flame/lib/src/particles/particle_range.dart b/packages/flame/lib/src/particles/particle_range.dart new file mode 100644 index 00000000000..befe7bd914e --- /dev/null +++ b/packages/flame/lib/src/particles/particle_range.dart @@ -0,0 +1,14 @@ +import 'dart:math'; + +/// An inclusive range of doubles from which per-particle values are sampled. +/// +/// Emitter properties that vary from particle to particle (lifespan, speed, +/// size, spin, etc.) are expressed as a `(min, max)` record. Use the same +/// value twice to get a constant, for example `(5, 5)`. +typedef ParticleRange = (double min, double max); + +/// Sampling helper for [ParticleRange]. +extension ParticleRangeExtension on ParticleRange { + /// Returns a value uniformly sampled between `min` and `max`. + double sample(Random random) => $1 + ($2 - $1) * random.nextDouble(); +} diff --git a/packages/flame/lib/src/particles/particle_renderer.dart b/packages/flame/lib/src/particles/particle_renderer.dart new file mode 100644 index 00000000000..581870997ae --- /dev/null +++ b/packages/flame/lib/src/particles/particle_renderer.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/src/particles/particle_buffer.dart'; + +/// Draws the live particles of an emitter component. +/// +/// Extend [TextureParticleRenderer] for batched texture rendering, or extend +/// this class (or use [CallbackParticleRenderer]) for full canvas access. +abstract class ParticleRenderer { + /// Called once when the owning component loads. Override to prepare + /// textures or other resources. + FutureOr onLoad() {} + + /// Renders all live particles in [particles]. The canvas is already + /// transformed to the emitter component's local coordinate system. + void render(Canvas canvas, ParticleBuffer particles); +} + +/// A renderer that delegates to a callback, for effects that need direct +/// canvas access. +/// +/// Nothing is batched, so this is slower than the texture renderers; prefer +/// those for large particle counts. +class CallbackParticleRenderer extends ParticleRenderer { + /// Creates a renderer that calls [renderCallback] every frame. + CallbackParticleRenderer(this.renderCallback); + + /// Called every frame with the canvas, already transformed to the emitter + /// component's local coordinate system, and the live particles. + final void Function(Canvas canvas, ParticleBuffer particles) renderCallback; + + @override + void render(Canvas canvas, ParticleBuffer particles) { + renderCallback(canvas, particles); + } +} + +/// Base class for renderers that draw every particle as a transformed, +/// tinted copy of a single texture region, batched into one +/// [Canvas.drawRawAtlas] call. +/// +/// Each particle is drawn centered on its position, rotated by its rotation, +/// and uniformly scaled so the texture region's width matches the particle's +/// current size. The particle's color tints the texture through +/// [BlendMode.modulate], so white texture pixels take on the particle color +/// exactly. +abstract class TextureParticleRenderer extends ParticleRenderer { + /// Creates a texture renderer. + /// + /// [blendMode] controls how particles composite onto the canvas; use + /// [BlendMode.plus] for additive glow effects. [paint] can be provided to + /// customize other paint properties. + TextureParticleRenderer({BlendMode? blendMode, Paint? paint}) + : paint = paint ?? Paint() { + if (blendMode != null) { + this.paint.blendMode = blendMode; + } + } + + /// The paint used for the batched draw call. + final Paint paint; + + /// The texture to draw, or null while the renderer has not loaded yet. + Image? get texture; + + /// The region of [texture] to draw, in texture pixels. + Rect get srcRect; + + Float32List _transforms = Float32List(0); + Float32List _rects = Float32List(0); + + @override + void render(Canvas canvas, ParticleBuffer particles) { + final texture = this.texture; + final count = particles.length; + if (texture == null || count == 0) { + return; + } + if (_transforms.length < count * 4) { + _transforms = Float32List(particles.capacity * 4); + _rects = Float32List(particles.capacity * 4); + } + final src = srcRect; + final anchorX = src.width / 2; + final anchorY = src.height / 2; + final invWidth = 1 / src.width; + for (var i = 0; i < count; i++) { + final scale = particles.size[i] * invWidth; + final rotation = particles.rotation[i]; + final scos = cos(rotation) * scale; + final ssin = sin(rotation) * scale; + final j = i * 4; + _transforms[j] = scos; + _transforms[j + 1] = ssin; + _transforms[j + 2] = particles.posX[i] - scos * anchorX + ssin * anchorY; + _transforms[j + 3] = particles.posY[i] - ssin * anchorX - scos * anchorY; + _rects[j] = src.left; + _rects[j + 1] = src.top; + _rects[j + 2] = src.right; + _rects[j + 3] = src.bottom; + } + canvas.drawRawAtlas( + texture, + Float32List.sublistView(_transforms, 0, count * 4), + Float32List.sublistView(_rects, 0, count * 4), + Int32List.sublistView(particles.color, 0, count), + BlendMode.modulate, + null, + paint, + ); + } +} diff --git a/packages/flame/lib/src/particles/rotating_particle.dart b/packages/flame/lib/src/particles/rotating_particle.dart deleted file mode 100644 index 32c9532af12..00000000000 --- a/packages/flame/lib/src/particles/rotating_particle.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'dart:math'; -import 'dart:ui'; - -import 'package:flame/src/components/mixins/single_child_particle.dart'; -import 'package:flame/src/particles/curved_particle.dart'; -import 'package:flame/src/particles/particle.dart'; - -/// A particle which rotates its child over the lifespan -/// between two given bounds in radians -class RotatingParticle extends CurvedParticle with SingleChildParticle { - @override - Particle child; - - final double from; - final double to; - - RotatingParticle({ - required this.child, - this.from = 0, - this.to = 2 * pi, - super.lifespan, - }); - - double get angle => lerpDouble(from, to, progress) ?? 0; - - @override - void render(Canvas canvas) { - canvas.save(); - canvas.rotate(angle); - super.render(canvas); - canvas.restore(); - } -} diff --git a/packages/flame/lib/src/particles/scaled_particle.dart b/packages/flame/lib/src/particles/scaled_particle.dart deleted file mode 100644 index b35886bb3bf..00000000000 --- a/packages/flame/lib/src/particles/scaled_particle.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/components/mixins/single_child_particle.dart'; -import 'package:flame/src/particles/particle.dart'; -import 'package:flame/src/particles/scaling_particle.dart'; - -/// Statically scales the given child [Particle] by given [scale]. -/// -/// If you're looking to scale the child over time, consider [ScalingParticle]. -class ScaledParticle extends Particle with SingleChildParticle { - @override - Particle child; - - final double scale; - - ScaledParticle({ - required this.child, - this.scale = 1.0, - super.lifespan, - }); - - @override - void render(Canvas canvas) { - canvas.save(); - canvas.scale(scale); - super.render(canvas); - canvas.restore(); - } -} diff --git a/packages/flame/lib/src/particles/scaling_particle.dart b/packages/flame/lib/src/particles/scaling_particle.dart deleted file mode 100644 index 9e55a242522..00000000000 --- a/packages/flame/lib/src/particles/scaling_particle.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/components/mixins/single_child_particle.dart'; -import 'package:flame/src/particles/curved_particle.dart'; -import 'package:flame/src/particles/particle.dart'; - -/// A particle which scale its child over the lifespan -/// between 1 and a provided scale. -class ScalingParticle extends CurvedParticle with SingleChildParticle { - @override - Particle child; - - final double to; - - ScalingParticle({ - required this.child, - this.to = 0, - super.lifespan, - super.curve, - }); - - double get scale => lerpDouble(1, to, progress) ?? 0; - - @override - void render(Canvas canvas) { - canvas.save(); - canvas.scale(scale); - super.render(canvas); - canvas.restore(); - } -} diff --git a/packages/flame/lib/src/particles/sprite_animation_particle.dart b/packages/flame/lib/src/particles/sprite_animation_particle.dart deleted file mode 100644 index 9afbdaa9f79..00000000000 --- a/packages/flame/lib/src/particles/sprite_animation_particle.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/anchor.dart'; -import 'package:flame/src/extensions/vector2.dart'; -import 'package:flame/src/particles/particle.dart'; -import 'package:flame/src/sprite_animation.dart'; -import 'package:flame/src/sprite_animation_ticker.dart'; - -export '../sprite_animation.dart'; - -/// A [Particle] which applies certain [SpriteAnimation]. -class SpriteAnimationParticle extends Particle { - final SpriteAnimation animation; - final SpriteAnimationTicker animationTicker; - final Vector2? position; - final Vector2? size; - final Anchor anchor; - final Paint? overridePaint; - final bool alignAnimationTime; - - SpriteAnimationParticle({ - required this.animation, - this.position, - this.size, - this.anchor = Anchor.center, - this.overridePaint, - super.lifespan, - this.alignAnimationTime = true, - }) : animationTicker = animation.createTicker(); - - @override - void setLifespan(double lifespan) { - super.setLifespan(lifespan); - - if (alignAnimationTime) { - animation.stepTime = lifespan / animation.frames.length; - animationTicker.reset(); - } - } - - @override - void render(Canvas canvas) { - animationTicker.getSprite().render( - canvas, - position: position, - size: size, - anchor: anchor, - overridePaint: overridePaint, - ); - } - - @override - void update(double dt) { - super.update(dt); - animationTicker.update(dt); - } -} diff --git a/packages/flame/lib/src/particles/sprite_particle.dart b/packages/flame/lib/src/particles/sprite_particle.dart deleted file mode 100644 index 58c38cb3e30..00000000000 --- a/packages/flame/lib/src/particles/sprite_particle.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/src/anchor.dart'; -import 'package:flame/src/extensions/vector2.dart'; -import 'package:flame/src/particles/particle.dart'; -import 'package:flame/src/sprite.dart'; - -export '../sprite.dart'; - -/// A [Particle] which applies certain [Sprite]. -class SpriteParticle extends Particle { - final Sprite sprite; - final Vector2? position; - final Vector2? size; - final Anchor anchor; - final Paint? overridePaint; - - SpriteParticle({ - required this.sprite, - this.position, - this.size, - this.anchor = Anchor.center, - this.overridePaint, - super.lifespan, - }); - - @override - void render(Canvas canvas) { - sprite.render( - canvas, - position: position, - size: size, - anchor: anchor, - overridePaint: overridePaint, - ); - } -} diff --git a/packages/flame/lib/src/particles/sprite_particle_renderer.dart b/packages/flame/lib/src/particles/sprite_particle_renderer.dart new file mode 100644 index 00000000000..1f43ae59e91 --- /dev/null +++ b/packages/flame/lib/src/particles/sprite_particle_renderer.dart @@ -0,0 +1,31 @@ +import 'dart:ui'; + +import 'package:flame/src/particles/particle_renderer.dart'; +import 'package:flame/src/sprite.dart'; + +/// Renders each particle as a [Sprite] (a region of an image), batched into +/// a single draw call. +/// +/// The sprite is drawn centered on the particle, scaled so its width matches +/// the particle's current size, and tinted by the emitter's `colorOverLife` +/// ramp (untinted when unset). +class SpriteParticleRenderer extends TextureParticleRenderer { + /// Renders every particle as [sprite]. + SpriteParticleRenderer(this.sprite, {super.blendMode, super.paint}); + + /// Renders every particle as the full [image]. + SpriteParticleRenderer.fromImage( + Image image, { + BlendMode? blendMode, + Paint? paint, + }) : this(Sprite(image), blendMode: blendMode, paint: paint); + + /// The sprite drawn for every particle. + final Sprite sprite; + + @override + Image get texture => sprite.image; + + @override + Rect get srcRect => sprite.src; +} diff --git a/packages/flame/lib/src/particles/translated_particle.dart b/packages/flame/lib/src/particles/translated_particle.dart deleted file mode 100644 index ca0d625e67f..00000000000 --- a/packages/flame/lib/src/particles/translated_particle.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flame/extensions.dart'; -import 'package:flame/src/components/mixins/single_child_particle.dart'; -import 'package:flame/src/particles/particle.dart'; - -/// Statically offset given child [Particle] by given [Vector2]. -/// -/// If you're looking to move the child, consider MovingParticle. -class TranslatedParticle extends Particle with SingleChildParticle { - @override - Particle child; - - final Vector2 offset; - - TranslatedParticle({ - required this.child, - required this.offset, - super.lifespan, - }); - - @override - void render(Canvas canvas) { - canvas.save(); - canvas.translateVector(offset); - super.render(canvas); - canvas.restore(); - } -} diff --git a/packages/flame/test/components/particle_system_component_test.dart b/packages/flame/test/components/particle_system_component_test.dart deleted file mode 100644 index 1347fe6bb90..00000000000 --- a/packages/flame/test/components/particle_system_component_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:canvas_test/canvas_test.dart'; -import 'package:flame/components.dart'; -import 'package:flame/game.dart'; -import 'package:flame/particles.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; - -class _MockParticle extends Mock implements Particle {} - -void main() { - group('ParticleSystem', () { - test('returns the progress of its particle', () { - final particle = _MockParticle(); - when(() => particle.progress).thenReturn(0.2); - - final progress = ParticleSystemComponent(particle: particle).progress; - expect(progress, equals(0.2)); - }); - - test('returns the progress of its particle', () { - final particle = _MockParticle(); - - final canvas = MockCanvas(); - ParticleSystemComponent(particle: particle).render(canvas); - - verify(() => particle.render(canvas)).called(1); - }); - - test('updates its particle', () { - final particle = _MockParticle(); - when(() => particle.shouldRemove).thenReturn(false); - - ParticleSystemComponent(particle: particle).update(0.1); - verify(() => particle.update(0.1)).called(1); - }); - - testWithFlameGame( - 'is removed when its particle is finished', - (FlameGame game) async { - final particle = _MockParticle(); - when(() => particle.shouldRemove).thenReturn(true); - - final component = ParticleSystemComponent(particle: particle); - await game.ensureAdd(component); - - game.update(1); - - expect(component.isRemoving, true); - }, - ); - }); -} diff --git a/packages/flame/test/particles/circle_particle_test.dart b/packages/flame/test/particles/circle_particle_test.dart deleted file mode 100644 index 36c22152850..00000000000 --- a/packages/flame/test/particles/circle_particle_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:canvas_test/canvas_test.dart'; -import 'package:flame/components.dart'; -import 'package:flame/particles.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -class _MockParticle extends Mock implements CircleParticle {} - -void main() { - group('CircleParticle', () { - test('Should render this Particle to given Canvas', () { - final particle = _MockParticle(); - - final canvas = MockCanvas(); - ParticleSystemComponent(particle: particle).render(canvas); - - verify(() => particle.render(canvas)).called(1); - }); - - testWithFlameGame( - 'Consider composing this with other Particle to achieve needed effects', - (game) async { - final childParticle = CircleParticle( - paint: Paint()..color = Colors.red, - lifespan: 2, - ); - - final component = ParticleSystemComponent( - particle: childParticle, - ); - - game.add(component); - await game.ready(); - game.update(1); - - expect(childParticle.progress, 0.5); - }, - ); - }); -} diff --git a/packages/flame/test/particles/color_ramp_test.dart b/packages/flame/test/particles/color_ramp_test.dart new file mode 100644 index 00000000000..21835208350 --- /dev/null +++ b/packages/flame/test/particles/color_ramp_test.dart @@ -0,0 +1,44 @@ +import 'dart:ui'; + +import 'package:flame/particles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ColorRamp', () { + test('solid keeps the same color over the whole lifetime', () { + final ramp = ColorRamp.solid(const Color(0xff123456)); + expect(ramp.colorAt(0), const Color(0xff123456)); + expect(ramp.colorAt(0.5), const Color(0xff123456)); + expect(ramp.colorAt(1), const Color(0xff123456)); + }); + + test('interpolates between the given colors', () { + final ramp = ColorRamp(const [Color(0xff000000), Color(0xffffffff)]); + expect(ramp.colorAt(0), const Color(0xff000000)); + expect(ramp.colorAt(1), const Color(0xffffffff)); + final middle = ramp.colorAt(0.5); + expect((middle.r * 255).round(), closeTo(128, 8)); + }); + + test('respects explicit stops', () { + final ramp = ColorRamp( + const [Color(0xff000000), Color(0xffffffff)], + stops: const [0, 0.1], + ); + expect(ramp.colorAt(0.5), const Color(0xffffffff)); + }); + + test('interpolates alpha', () { + final ramp = ColorRamp(const [Color(0xffffffff), Color(0x00ffffff)]); + expect(ramp.colorAt(0).a, closeTo(1, 0.05)); + expect(ramp.colorAt(0.5).a, closeTo(0.5, 0.05)); + expect(ramp.colorAt(1).a, closeTo(0, 0.05)); + }); + + test('clamps the input to the unit interval', () { + final ramp = ColorRamp(const [Color(0xff000000), Color(0xffffffff)]); + expect(ramp.colorAt(-1), ramp.colorAt(0)); + expect(ramp.colorAt(2), ramp.colorAt(1)); + }); + }); +} diff --git a/packages/flame/test/particles/component_particle_test.dart b/packages/flame/test/particles/component_particle_test.dart deleted file mode 100644 index 51966bc725b..00000000000 --- a/packages/flame/test/particles/component_particle_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:ui'; - -import 'package:flame/components.dart'; -import 'package:flame/particles.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ComponentParticle', () { - test( - 'CircleComponent renders without error when used outside of the ' - 'component lifecycle', - () { - final particle = ComponentParticle( - component: CircleComponent(radius: 2), - lifespan: 1, - ); - - expect( - () => particle.render(Canvas(PictureRecorder())), - returnsNormally, - ); - }, - ); - }); -} diff --git a/packages/flame/test/particles/composed_particle_test.dart b/packages/flame/test/particles/composed_particle_test.dart deleted file mode 100644 index d428b8018e1..00000000000 --- a/packages/flame/test/particles/composed_particle_test.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'package:flame/particles.dart'; -import 'package:flame/src/components/particle_system_component.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ComposedParticle', () { - testWithFlameGame('particles with parent lifespan applied to children', ( - game, - ) async { - final childParticle1 = CircleParticle( - paint: Paint()..color = Colors.red, - lifespan: 1, - ); - final childParticle2 = CircleParticle( - paint: Paint()..color = Colors.red, - lifespan: 3, - ); - - final particle = ComposedParticle( - children: [ - childParticle1, - childParticle2, - ], - lifespan: 2, - ); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - - expect(particle.progress, 0.5); - expect(childParticle1.progress, 0.5); - expect(childParticle2.progress, 0.5); - expect(particle.children.length, 2); - - game.update(1); - - expect(particle.progress, 1); - expect(childParticle1.progress, 1); - expect(childParticle2.progress, 1); - expect(particle.children.length, 2); - }); - - testWithFlameGame('particles without parent lifespan applied to children', ( - game, - ) async { - final childParticle1 = CircleParticle( - paint: Paint()..color = Colors.red, - lifespan: 1, - ); - final childParticle2 = CircleParticle( - paint: Paint()..color = Colors.red, - lifespan: 4, - ); - - final particle = ComposedParticle( - children: [ - childParticle1, - childParticle2, - ], - lifespan: 2, - applyLifespanToChildren: false, - ); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - - expect(particle.progress, 0.5); - expect(childParticle1.progress, 1); - expect(childParticle2.progress, 0.25); - expect(particle.children.length, 2); - - game.update(1); - - expect(particle.progress, 1); - expect(childParticle1.progress, 1); - expect(childParticle2.progress, 0.5); - expect(particle.children.length, 1); - }); - - testWithFlameGame( - 'generate particles without parent lifespan applied to children', - (game) async { - const particlesCount = 15; - final component = ParticleSystemComponent( - particle: Particle.generate( - count: particlesCount, - generator: (i) { - return CircleParticle( - paint: Paint()..color = Colors.red, - lifespan: 5, - ); - }, - applyLifespanToChildren: false, - lifespan: 10, - ), - ); - - game.add(component); - await game.ready(); - game.update(1); - - expect(component.particle!.progress, 0.1); - final children1 = (component.particle! as ComposedParticle).children; - expect(children1.length, particlesCount); - for (final child in children1) { - expect(child.progress, 0.2); - } - - game.update(1); - - expect(component.particle!.progress, 0.2); - final children2 = (component.particle! as ComposedParticle).children; - expect(children2.length, particlesCount); - for (final child in children2) { - expect(child.progress, 0.4); - } - }, - ); - }); -} diff --git a/packages/flame/test/particles/computed_particle_test.dart b/packages/flame/test/particles/computed_particle_test.dart deleted file mode 100644 index 6081b6ff56e..00000000000 --- a/packages/flame/test/particles/computed_particle_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flame/particles.dart'; -import 'package:flame/src/components/particle_system_component.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ComputedParticle', () { - testWithFlameGame( - 'Particle container which delegates rendering particle on each frame', - (game) async { - final cellSize = game.size / 5.0; - final halfCellSize = cellSize / 2; - - final particle = ComputedParticle( - renderer: (canvas, particle) { - canvas.drawCircle( - Offset.zero, - particle.progress * halfCellSize.x, - Paint() - ..color = Color.lerp( - Colors.red, - Colors.blue, - particle.progress, - )!, - ); - }, - lifespan: 2, - ); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - expect(particle.progress, 0.5); - - game.update(1); - expect(particle.progress, 1); - }, - ); - - testWithFlameGame('Particle to use custom tweening', (game) async { - final cellSize = game.size / 5.0; - final halfCellSize = cellSize / 2; - final steppedTween = StepTween(begin: 0, end: 5); - - final particle = ComputedParticle( - lifespan: 2, - renderer: (canvas, particle) { - const steps = 5; - final steppedProgress = - steppedTween.transform(particle.progress) / steps; - - canvas.drawCircle( - Offset.zero, - (1 - steppedProgress) * halfCellSize.x, - Paint() - ..color = Color.lerp( - Colors.red, - Colors.blue, - steppedProgress, - )!, - ); - }, - ); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - expect(particle.progress, 0.5); - - game.update(1); - expect(particle.progress, 1); - }); - }); -} diff --git a/packages/flame/test/particles/curved_particle_test.dart b/packages/flame/test/particles/curved_particle_test.dart deleted file mode 100644 index a197edd11c5..00000000000 --- a/packages/flame/test/particles/curved_particle_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flame/src/components/particle_system_component.dart'; -import 'package:flame/src/particles/curved_particle.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('CurvedParticle', () { - test('A Particle which applies certain Curve', () { - final particle = CurvedParticle(); - - expect(particle.curve, Curves.linear); - }); - - test('A Particle which applies certain Curve', () { - final particle = CurvedParticle(curve: Curves.decelerate); - - expect(particle.curve, Curves.decelerate); - }); - - testWithFlameGame( - 'A Particle which applies certain Curve for easing or other purposes' - ' to its progress getter.', - (game) async { - final particle = CurvedParticle(lifespan: 2); - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - - expect(particle.curve, Curves.linear); - expect(particle.progress, 0.5); - }, - ); - }); -} diff --git a/packages/flame/test/particles/emitter_shape_test.dart b/packages/flame/test/particles/emitter_shape_test.dart new file mode 100644 index 00000000000..d5ace0f5b7c --- /dev/null +++ b/packages/flame/test/particles/emitter_shape_test.dart @@ -0,0 +1,47 @@ +import 'dart:math'; + +import 'package:flame/extensions.dart'; +import 'package:flame/particles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EmitterShape', () { + test('PointEmitterShape always samples the origin', () { + final random = Random(42); + final out = Vector2.all(99); + const PointEmitterShape().samplePosition(random, out); + expect(out, Vector2.zero()); + }); + + test('CircleEmitterShape samples inside the circle', () { + final random = Random(42); + const shape = CircleEmitterShape(10); + final out = Vector2.zero(); + for (var i = 0; i < 100; i++) { + shape.samplePosition(random, out); + expect(out.length, lessThanOrEqualTo(10)); + } + }); + + test('CircleEmitterShape with edgeOnly samples on the edge', () { + final random = Random(42); + const shape = CircleEmitterShape(10, edgeOnly: true); + final out = Vector2.zero(); + for (var i = 0; i < 100; i++) { + shape.samplePosition(random, out); + expect(out.length, closeTo(10, 1e-6)); + } + }); + + test('RectangleEmitterShape samples inside the centered rectangle', () { + final random = Random(42); + const shape = RectangleEmitterShape(20, 10); + final out = Vector2.zero(); + for (var i = 0; i < 100; i++) { + shape.samplePosition(random, out); + expect(out.x.abs(), lessThanOrEqualTo(10)); + expect(out.y.abs(), lessThanOrEqualTo(5)); + } + }); + }); +} diff --git a/packages/flame/test/particles/moving_particle_test.dart b/packages/flame/test/particles/moving_particle_test.dart deleted file mode 100644 index 4a4eec3fef9..00000000000 --- a/packages/flame/test/particles/moving_particle_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flame/extensions.dart'; -import 'package:flame/src/components/particle_system_component.dart'; -import 'package:flame/src/particles/circle_particle.dart'; -import 'package:flame/src/particles/moving_particle.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('MovingParticle', () { - testWithFlameGame( - 'Particle which is moving from one predefined position to another one', - (game) async { - final childParticle = CircleParticle( - paint: Paint()..color = Colors.red, - lifespan: 2, - ); - - final particle = MovingParticle( - from: Vector2(-20, -20), - to: Vector2(20, 20), - child: childParticle, - ); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - expect(particle.progress, 1.0); - }, - ); - }); -} diff --git a/packages/flame/test/particles/particle_buffer_test.dart b/packages/flame/test/particles/particle_buffer_test.dart new file mode 100644 index 00000000000..f6689058253 --- /dev/null +++ b/packages/flame/test/particles/particle_buffer_test.dart @@ -0,0 +1,56 @@ +import 'package:flame/particles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ParticleBuffer', () { + test('spawn hands out slots until the capacity is reached', () { + final buffer = ParticleBuffer(3); + expect(buffer.spawn(), 0); + expect(buffer.spawn(), 1); + expect(buffer.spawn(), 2); + expect(buffer.isFull, isTrue); + expect(buffer.spawn(), -1); + expect(buffer.length, 3); + }); + + test('removeAt swaps the last particle into the removed slot', () { + final buffer = ParticleBuffer(3); + for (var i = 0; i < 3; i++) { + final index = buffer.spawn(); + buffer.posX[index] = i.toDouble(); + } + buffer.removeAt(0); + expect(buffer.length, 2); + expect(buffer.posX[0], 2); + expect(buffer.posX[1], 1); + }); + + test('removing the last particle does not copy anything', () { + final buffer = ParticleBuffer(2); + buffer.spawn(); + final index = buffer.spawn(); + buffer.posX[index] = 42; + buffer.removeAt(1); + expect(buffer.length, 1); + buffer.removeAt(0); + expect(buffer.length, 0); + }); + + test('progressAt reports age relative to lifespan', () { + final buffer = ParticleBuffer(1); + final index = buffer.spawn(); + buffer.age[index] = 0.5; + buffer.invLifespan[index] = 1 / 2.0; + expect(buffer.progressAt(index), 0.25); + }); + + test('clear removes all particles', () { + final buffer = ParticleBuffer(4); + buffer.spawn(); + buffer.spawn(); + buffer.clear(); + expect(buffer.length, 0); + expect(buffer.isFull, isFalse); + }); + }); +} diff --git a/packages/flame/test/particles/particle_curve_test.dart b/packages/flame/test/particles/particle_curve_test.dart new file mode 100644 index 00000000000..527edb674ba --- /dev/null +++ b/packages/flame/test/particles/particle_curve_test.dart @@ -0,0 +1,62 @@ +import 'dart:math'; + +import 'package:flame/particles.dart'; +import 'package:flutter/animation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ParticleCurve', () { + test('interpolates linearly between from and to', () { + final curve = ParticleCurve(2, 4); + expect(curve.transform(0), 2); + expect(curve.transform(0.5), closeTo(3, 1e-6)); + expect(curve.transform(1), 4); + }); + + test('applies an animation curve', () { + final linear = ParticleCurve(0, 1); + final eased = ParticleCurve(0, 1, curve: Curves.easeIn); + expect(eased.transform(0), closeTo(0, 1e-6)); + expect(eased.transform(1), closeTo(1, 1e-6)); + expect(eased.transform(0.5), lessThan(linear.transform(0.5))); + }); + + test('constant keeps the same value', () { + final curve = ParticleCurve.constant(7); + expect(curve.transform(0), 7); + expect(curve.transform(0.3), 7); + expect(curve.transform(1), 7); + }); + + test('custom bakes an arbitrary function', () { + final curve = ParticleCurve.custom((t) => sin(t * pi)); + expect(curve.transform(0), closeTo(0, 1e-3)); + expect(curve.transform(0.5), closeTo(1, 1e-3)); + expect(curve.transform(1), closeTo(0, 1e-3)); + }); + + test('clamps the input to the unit interval', () { + final curve = ParticleCurve(1, 2); + expect(curve.transform(-1), 1); + expect(curve.transform(2), 2); + }); + }); + + group('ParticleRange', () { + test('samples uniformly within the range', () { + final random = Random(42); + const range = (2.0, 5.0); + for (var i = 0; i < 100; i++) { + final value = range.sample(random); + expect(value, greaterThanOrEqualTo(2)); + expect(value, lessThan(5)); + } + }); + + test('a degenerate range is a constant', () { + final random = Random(1); + const range = (3.0, 3.0); + expect(range.sample(random), 3); + }); + }); +} diff --git a/packages/flame/test/particles/particle_emitter_component_test.dart b/packages/flame/test/particles/particle_emitter_component_test.dart new file mode 100644 index 00000000000..2069b53db14 --- /dev/null +++ b/packages/flame/test/particles/particle_emitter_component_test.dart @@ -0,0 +1,386 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/particles.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ParticleEmitterComponent _component({ + required ParticleEmitter emitter, + ParticleRenderer? renderer, + bool removeOnFinish = true, + bool worldSpace = false, + bool emitting = true, + Vector2? position, +}) { + return ParticleEmitterComponent( + emitter: emitter, + renderer: renderer ?? CallbackParticleRenderer((_, _) {}), + removeOnFinish: removeOnFinish, + worldSpace: worldSpace, + emitting: emitting, + position: position, + random: Random(42), + ); +} + +void main() { + group('ParticleEmitterComponent', () { + testWithFlameGame('continuous emission follows the rate', (game) async { + final component = _component( + emitter: ParticleEmitter(rate: 100, lifespan: (10, 10)), + ); + await game.ensureAdd(component); + game.update(0.5); + expect(component.particleCount, 50); + game.update(0.5); + expect(component.particleCount, 100); + }); + + testWithFlameGame('fractional spawns accumulate over frames', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter(rate: 4, lifespan: (10, 10)), + ); + await game.ensureAdd(component); + game.update(0.125); + expect(component.particleCount, 0); + game.update(0.125); + expect(component.particleCount, 1); + for (var i = 0; i < 6; i++) { + game.update(0.125); + } + expect(component.particleCount, 4); + }); + + testWithFlameGame('bursts fire at their time on the timeline', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + bursts: const [EmitterBurst(0, 5), EmitterBurst(0.5, 10)], + lifespan: (10, 10), + ), + ); + await game.ensureAdd(component); + game.update(0.1); + expect(component.particleCount, 5); + game.update(0.3); + expect(component.particleCount, 5); + game.update(0.2); + expect(component.particleCount, 15); + }); + + testWithFlameGame('particles die when their lifespan ends', (game) async { + final component = _component( + emitter: ParticleEmitter( + bursts: const [EmitterBurst(0, 8)], + lifespan: (0.5, 0.5), + ), + removeOnFinish: false, + ); + await game.ensureAdd(component); + game.update(0.1); + expect(component.particleCount, 8); + game.update(0.5); + expect(component.particleCount, 0); + expect(component.isFinished, isTrue); + }); + + testWithFlameGame('removeOnFinish removes the finished component', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + bursts: const [EmitterBurst(0, 3)], + lifespan: (0.2, 0.2), + ), + ); + await game.ensureAdd(component); + game.update(0.1); + expect(component.particleCount, 3); + game.update(0.3); + game.update(0); + expect(game.children.contains(component), isFalse); + }); + + testWithFlameGame('an endless emitter is never auto-removed', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter(rate: 1, lifespan: (0.1, 0.1)), + ); + await game.ensureAdd(component); + for (var i = 0; i < 20; i++) { + game.update(0.5); + } + expect(game.children.contains(component), isTrue); + }); + + testWithFlameGame('maxParticles caps the number of live particles', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter(maxParticles: 10, lifespan: (10, 10)), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(100); + expect(component.particleCount, 10); + }); + + testWithFlameGame('emit spawns immediately regardless of the timeline', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter(lifespan: (10, 10)), + emitting: false, + ); + await game.ensureAdd(component); + expect(component.particleCount, 0); + component.emit(7); + expect(component.particleCount, 7); + }); + + testWithFlameGame('stop pauses emission and start resumes it', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter(rate: 10, lifespan: (100, 100)), + ); + await game.ensureAdd(component); + game.update(1); + expect(component.particleCount, 10); + component.stop(); + game.update(1); + expect(component.particleCount, 10); + component.start(); + game.update(1); + expect(component.particleCount, 20); + }); + + testWithFlameGame('start after a finished emission restarts it', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + bursts: const [EmitterBurst(0, 3)], + lifespan: (10, 10), + ), + removeOnFinish: false, + ); + await game.ensureAdd(component); + game.update(0.1); + expect(component.particleCount, 3); + component.start(); + game.update(0.1); + expect(component.particleCount, 6); + }); + + testWithFlameGame('loop restarts the emission timeline', (game) async { + final component = _component( + emitter: ParticleEmitter( + bursts: const [EmitterBurst(0, 1)], + duration: 1, + loop: true, + lifespan: (100, 100), + ), + ); + await game.ensureAdd(component); + game.update(0.5); + expect(component.particleCount, 1); + game.update(0.6); + game.update(0.1); + expect(component.particleCount, 2); + }); + + testWithFlameGame('a rate-based emitter stops after its duration', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + rate: 10, + duration: 1, + lifespan: (100, 100), + ), + removeOnFinish: false, + ); + await game.ensureAdd(component); + game.update(1); + expect(component.particleCount, 10); + game.update(1); + expect(component.particleCount, 10); + }); + + testWithFlameGame('worldSpace leaves live particles behind', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter(lifespan: (10, 10)), + emitting: false, + worldSpace: true, + position: Vector2.zero(), + ); + await game.ensureAdd(component); + game.update(0); + component.emit(1); + expect(component.particles.posX[0], 0); + component.position.x += 10; + game.update(0); + expect(component.particles.posX[0], -10); + expect(component.particles.posY[0], 0); + }); + + testWithFlameGame('gravity accelerates particles', (game) async { + final component = _component( + emitter: ParticleEmitter( + gravity: Vector2(0, 100), + lifespan: (10, 10), + ), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(1); + game.update(1); + expect(component.particles.velY[0], closeTo(100, 1e-6)); + game.update(1); + expect(component.particles.velY[0], closeTo(200, 1e-6)); + }); + + testWithFlameGame('drag slows particles down', (game) async { + final component = _component( + emitter: ParticleEmitter( + speed: (100, 100), + spread: 0, + drag: 1, + lifespan: (10, 10), + ), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(1); + final initialSpeed = component.particles.velX[0]; + game.update(1); + expect(component.particles.velX[0], lessThan(initialSpeed)); + }); + + testWithFlameGame('scaleOverLife scales the particle size', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + size: (10, 10), + scaleOverLife: ParticleCurve(1, 0), + ), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(1); + expect(component.particles.size[0], 10); + game.update(0.5); + expect(component.particles.size[0], closeTo(5, 1e-3)); + }); + + testWithFlameGame('opacityOverLife fades the particle color', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + opacityOverLife: ParticleCurve(1, 0), + ), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(1); + expect((component.particles.color[0] >> 24) & 0xff, 0xff); + game.update(0.5); + expect((component.particles.color[0] >> 24) & 0xff, closeTo(128, 2)); + }); + + testWithFlameGame('colorOverLife tints the particle color', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + colorOverLife: ColorRamp( + const [Color(0xffff0000), Color(0xff0000ff)], + ), + ), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(1); + expect(component.particles.color[0] & 0xffffffff, 0xffff0000); + game.update(0.99); + final color = component.particles.color[0]; + expect(color & 0xff, closeTo(0xfc, 4)); + expect((color >> 16) & 0xff, closeTo(0x03, 4)); + }); + + testWithFlameGame('rotateToVelocity points particles along velocity', ( + game, + ) async { + final component = _component( + emitter: ParticleEmitter( + speed: (100, 100), + direction: pi / 2, + spread: 0, + rotateToVelocity: true, + lifespan: (10, 10), + ), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(1); + expect(component.particles.rotation[0], closeTo(pi / 2, 1e-6)); + game.update(0.1); + expect(component.particles.rotation[0], closeTo(pi / 2, 1e-6)); + }); + + testWithFlameGame('seeded components behave deterministically', ( + game, + ) async { + ParticleEmitterComponent build() { + return ParticleEmitterComponent( + emitter: ParticleEmitter( + rate: 50, + lifespan: (0.5, 2), + shape: const CircleEmitterShape(20), + speed: (10, 100), + ), + renderer: CallbackParticleRenderer((_, _) {}), + random: Random(1337), + ); + } + + final a = build(); + final b = build(); + await game.ensureAdd(a); + await game.ensureAdd(b); + game.update(0.7); + expect(a.particleCount, b.particleCount); + for (var i = 0; i < a.particleCount; i++) { + expect(a.particles.posX[i], b.particles.posX[i]); + expect(a.particles.posY[i], b.particles.posY[i]); + } + }); + + testWithFlameGame('render delegates to the renderer', (game) async { + var rendered = 0; + final component = _component( + emitter: ParticleEmitter(lifespan: (10, 10)), + renderer: CallbackParticleRenderer((_, particles) { + rendered = particles.length; + }), + emitting: false, + ); + await game.ensureAdd(component); + component.emit(4); + final recorder = PictureRecorder(); + component.render(Canvas(recorder)); + expect(rendered, 4); + }); + }); +} diff --git a/packages/flame/test/particles/particle_renderer_test.dart b/packages/flame/test/particles/particle_renderer_test.dart new file mode 100644 index 00000000000..9655f66611a --- /dev/null +++ b/packages/flame/test/particles/particle_renderer_test.dart @@ -0,0 +1,83 @@ +import 'dart:ui'; + +import 'package:flame/particles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _makeImage(int width, int height) { + final recorder = PictureRecorder(); + Canvas(recorder).drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = const Color(0xffffffff), + ); + return recorder.endRecording().toImage(width, height); +} + +ParticleBuffer _bufferWithParticles(int count) { + final buffer = ParticleBuffer(count + 4); + for (var i = 0; i < count; i++) { + final index = buffer.spawn(); + buffer.posX[index] = i * 10.0; + buffer.posY[index] = i * 5.0; + buffer.size[index] = 8; + buffer.rotation[index] = i * 0.1; + buffer.color[index] = 0xffffffff; + } + return buffer; +} + +void main() { + group('CircleParticleRenderer', () { + test('generates its texture on load', () async { + final renderer = CircleParticleRenderer(); + expect(renderer.texture, isNull); + await renderer.onLoad(); + expect(renderer.texture, isNotNull); + expect(renderer.srcRect.width, renderer.texture!.width); + }); + + test('loading twice keeps the same texture', () async { + final renderer = CircleParticleRenderer(); + await renderer.onLoad(); + final texture = renderer.texture; + await renderer.onLoad(); + expect(renderer.texture, same(texture)); + }); + + test('renders live particles without throwing', () async { + final renderer = CircleParticleRenderer(softness: 0.5); + await renderer.onLoad(); + final canvas = Canvas(PictureRecorder()); + renderer.render(canvas, _bufferWithParticles(16)); + }); + + test('rendering before load or with no particles is a no-op', () async { + final renderer = CircleParticleRenderer(); + final canvas = Canvas(PictureRecorder()); + renderer.render(canvas, _bufferWithParticles(3)); + await renderer.onLoad(); + renderer.render(canvas, _bufferWithParticles(0)); + }); + }); + + group('SpriteParticleRenderer', () { + test('uses the full image by default', () async { + final image = await _makeImage(16, 8); + final renderer = SpriteParticleRenderer.fromImage(image); + expect(renderer.texture, same(image)); + expect(renderer.srcRect, const Rect.fromLTWH(0, 0, 16, 8)); + }); + + test('renders live particles without throwing', () async { + final image = await _makeImage(16, 16); + final renderer = SpriteParticleRenderer.fromImage(image); + await renderer.onLoad(); + final canvas = Canvas(PictureRecorder()); + renderer.render(canvas, _bufferWithParticles(32)); + }); + + test('blendMode is applied to the paint', () { + final renderer = CircleParticleRenderer(blendMode: BlendMode.plus); + expect(renderer.paint.blendMode, BlendMode.plus); + }); + }); +} diff --git a/packages/flame/test/particles/scaled_particle_test.dart b/packages/flame/test/particles/scaled_particle_test.dart deleted file mode 100644 index 5125a75261d..00000000000 --- a/packages/flame/test/particles/scaled_particle_test.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:math'; - -import 'package:flame/src/components/particle_system_component.dart'; -import 'package:flame/src/particles/computed_particle.dart'; -import 'package:flame/src/particles/rotating_particle.dart'; -import 'package:flame/src/particles/scaled_particle.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ScaledParticle', () { - testWithFlameGame( - 'A particle which rotates its child over the lifespan between two ' - 'given bounds in radians', - (game) async { - final paint = Paint()..color = Colors.red; - final rect = ComputedParticle( - renderer: (canvas, _) => canvas.drawRect( - Rect.fromCenter(center: Offset.zero, width: 10, height: 10), - paint, - ), - ); - - final particle = ScaledParticle( - lifespan: 2, - child: rect.rotating(to: pi / 2), - ); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - - expect(particle.scale, 1.0); - expect(particle.child, isInstanceOf()); - expect(particle.child.progress, 0.5); - }, - ); - }); -} diff --git a/packages/flame/test/particles/scaling_particle_test.dart b/packages/flame/test/particles/scaling_particle_test.dart deleted file mode 100644 index 93cd316ce15..00000000000 --- a/packages/flame/test/particles/scaling_particle_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flame/src/components/particle_system_component.dart'; -import 'package:flame/src/particles/computed_particle.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ScalingParticle', () { - testWithFlameGame('A particle which scale its child over the lifespan ' - 'between 1 and a provided scale', (game) async { - final paint = Paint()..color = Colors.red; - final rect = ComputedParticle( - lifespan: 2, - renderer: (canvas, _) => canvas.drawRect( - Rect.fromCenter(center: Offset.zero, width: 10, height: 10), - paint, - ), - ); - - final particle = rect.scaling(to: 0.5, curve: Curves.easeIn); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - - expect(particle.scale, 0.841796875); - expect(particle.curve, Curves.easeIn); - expect(particle.progress, 0.31640625); - expect(particle.child, isInstanceOf()); - expect(particle.child.progress, 0.5); - }); - }); -} diff --git a/packages/flame/test/particles/sprite_particle_test.dart b/packages/flame/test/particles/sprite_particle_test.dart deleted file mode 100644 index 99287e023ec..00000000000 --- a/packages/flame/test/particles/sprite_particle_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:canvas_test/canvas_test.dart'; -import 'package:flame/extensions.dart'; -import 'package:flame/src/components/particle_system_component.dart'; -import 'package:flame/src/particles/sprite_particle.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -class _MockParticle extends Mock implements SpriteParticle {} - -Future main() async { - // Generate an image - final image = await generateImage(); - - group('SpriteParticle', () { - test('Should render this Particle to given Canvas', () { - final particle = _MockParticle(); - - final canvas = MockCanvas(); - ParticleSystemComponent(particle: particle).render(canvas); - - verify(() => particle.render(canvas)).called(1); - }); - - final sprite = Sprite(image); - testWithFlameGame( - 'SpriteParticle allows easily embed Flames Sprite into the effect', - (game) async { - final particle = SpriteParticle( - sprite: sprite, - size: Vector2(50, 50), - lifespan: 2, - ); - - final component = ParticleSystemComponent( - particle: particle, - ); - - game.add(component); - await game.ready(); - game.update(1); - expect(particle.progress, 0.5); - }, - ); - }); -}