feat: adding ParticleSystemComponent (#1489)

This commit is contained in:
Erick
2022-03-26 12:20:31 -03:00
committed by GitHub
parent a3b7de0acd
commit 6891eaaa24
8 changed files with 231 additions and 50 deletions

View File

@ -1,21 +1,21 @@
# Particles # Particles
Flame offers a basic, yet robust and extendable particle system. The core concept of this system is 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 `ParticleComponent`. the `Particle` class, which is very similar in its behavior to the `ParticleSystemComponent`.
The most basic usage of a `Particle` with `FlameGame` would look as following: The most basic usage of a `Particle` with `FlameGame` would look as following:
```dart ```dart
import 'package:flame/components.dart'; import 'package:flame/components.dart';
// ... // ...
game.add( game.add(
// Wrapping a Particle with ParticleComponent // Wrapping a Particle with ParticleSystemComponent
// which maps Component lifecycle hooks to Particle ones // which maps Component lifecycle hooks to Particle ones
// and embeds a trigger for removing the component. // and embeds a trigger for removing the component.
ParticleComponent( ParticleSystemComponent(
CircleParticle(), particle: CircleParticle(),
), ),
); );
``` ```
@ -40,15 +40,15 @@ Random rnd = Random();
Vector2 randomVector2() => (Vector2.random(rnd) - Vector2.random(rnd)) * 200; Vector2 randomVector2() => (Vector2.random(rnd) - Vector2.random(rnd)) * 200;
// Composition. // Composition.
// //
// Defining a particle effect as a set of nested behaviors from top to bottom, one within another: // Defining a particle effect as a set of nested behaviors from top to bottom, one within another:
// ParticleComponent // ParticleSystemComponent
// > ComposedParticle // > ComposedParticle
// > AcceleratedParticle // > AcceleratedParticle
// > CircleParticle // > CircleParticle
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
Particle.generate( particle: Particle.generate(
count: 10, count: 10,
generator: (i) => AcceleratedParticle( generator: (i) => AcceleratedParticle(
acceleration: randomVector2(), acceleration: randomVector2(),
@ -61,12 +61,12 @@ game.add(
); );
// Chaining. // Chaining.
// //
// Expresses the same behavior as above, but with a more fluent API. // Expresses the same behavior as above, but with a more fluent API.
// Only Particles with SingleChildParticle mixin can be used as chainable behaviors. // Only Particles with SingleChildParticle mixin can be used as chainable behaviors.
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
Particle.generate( particle: Particle.generate(
count: 10, count: 10,
generator: (i) => pt.CircleParticle(paint: Paint()..color = Colors.red) generator: (i) => pt.CircleParticle(paint: Paint()..color = Colors.red)
) )
@ -74,19 +74,19 @@ game.add(
); );
// Computed Particle. // Computed Particle.
// //
// All the behaviors are defined explicitly. Offers greater flexibility // All the behaviors are defined explicitly. Offers greater flexibility
// compared to built-in behaviors. // compared to built-in behaviors.
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
Particle.generate( particle: Particle.generate(
count: 10, count: 10,
generator: (i) { generator: (i) {
Vector2 position = Vector2.zero(); Vector2 position = Vector2.zero();
Vector2 speed = Vector2.zero(); Vector2 speed = Vector2.zero();
final acceleration = randomVector2(); final acceleration = randomVector2();
final paint = Paint()..color = Colors.red; final paint = Paint()..color = Colors.red;
return ComputedParticle( return ComputedParticle(
renderer: (canvas, _) { renderer: (canvas, _) {
speed += acceleration; speed += acceleration;
@ -107,10 +107,10 @@ You can find more examples of how to use different built-in particles in various
## Lifecycle ## Lifecycle
A behavior common to all `Particle`s is that all of them accept a `lifespan` argument. This value is A behavior common to all `Particle`s is that all of them accept a `lifespan` argument. This value is
used to make the `ParticleComponent` remove itself once its internal `Particle` has reached the end used to make the `ParticleSystemComponent` remove itself once its internal `Particle` has reached
of its life. Time within the `Particle` itself is tracked using the Flame `Timer` class. It can be the end of its life. Time within the `Particle` itself is tracked using the Flame `Timer` class. It
configured with a `double`, represented in seconds (with microsecond precision) by passing it into can be configured with a `double`, represented in seconds (with microsecond precision) by passing
the corresponding `Particle` constructor. it into the corresponding `Particle` constructor.
```dart ```dart
Particle(lifespan: .2); // will live for 200ms. Particle(lifespan: .2); // will live for 200ms.
@ -118,7 +118,7 @@ Particle(lifespan: 4); // will live for 4s.
``` ```
It is also possible to reset a `Particle`'s lifespan by using the `setLifespan` method, which also It is also possible to reset a `Particle`'s lifespan by using the `setLifespan` method, which also
accepts a `double` of seconds. accepts a `double` of seconds.
```dart ```dart
final particle = Particle(lifespan: 2); final particle = Particle(lifespan: 2);
@ -134,7 +134,7 @@ as the `value` property of the `AnimationController` class in Flutter.
```dart ```dart
final particle = Particle(lifespan: 2.0); final particle = Particle(lifespan: 2.0);
game.add(ParticleComponent(particle)); game.add(ParticleSystemComponent(particle: particle));
// Will print values from 0 to 1 with step of .1: 0, 0.1, 0.2 ... 0.9, 1.0. // 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)); Timer.periodic(duration * .1, () => print(particle.progress));
@ -172,8 +172,8 @@ layer.
```dart ```dart
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
TranslatedParticle( particle: TranslatedParticle(
// Will translate the child Particle effect to the center of game canvas. // Will translate the child Particle effect to the center of game canvas.
offset: game.size / 2, offset: game.size / 2,
child: Particle(), child: Particle(),
@ -189,8 +189,8 @@ Moves the child `Particle` between the `from` and `to` `Vector2`s during its lif
```dart ```dart
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
MovingParticle( particle: MovingParticle(
// Will move from corner to corner of the game canvas. // Will move from corner to corner of the game canvas.
from: Vector2.zero(), from: Vector2.zero(),
to: game.size, to: game.size,
@ -216,8 +216,8 @@ final rnd = Random();
Vector2 randomVector2() => (Vector2.random(rnd) - Vector2.random(rnd)) * 100; Vector2 randomVector2() => (Vector2.random(rnd) - Vector2.random(rnd)) * 100;
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
AcceleratedParticle( particle: AcceleratedParticle(
// Will fire off in the center of game canvas // Will fire off in the center of game canvas
position: game.canvasSize/2, position: game.canvasSize/2,
// With random initial speed of Vector2(-100..100, 0..-100) // With random initial speed of Vector2(-100..100, 0..-100)
@ -241,8 +241,8 @@ desired positioning.
```dart ```dart
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
CircleParticle( particle: CircleParticle(
radius: game.size.x / 2, radius: game.size.x / 2,
paint: Paint()..color = Colors.red.withOpacity(.5), paint: Paint()..color = Colors.red.withOpacity(.5),
), ),
@ -256,8 +256,8 @@ Allows you to embed a `Sprite` into your particle effects.
```dart ```dart
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
SpriteParticle( particle: SpriteParticle(
sprite: Sprite('sprite.png'), sprite: Sprite('sprite.png'),
size: Vector2(64, 64), size: Vector2(64, 64),
), ),
@ -267,7 +267,7 @@ game.add(
## ImageParticle ## ImageParticle
Renders given `dart:ui` image within the particle tree. Renders given `dart:ui` image within the particle tree.
```dart ```dart
// During game initialisation // During game initialisation
@ -281,8 +281,8 @@ await Flame.images.loadAll(const [
final image = await Flame.images.load('image.png'); final image = await Flame.images.load('image.png');
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
ImageParticle( particle: ImageParticle(
size: Vector2.all(24), size: Vector2.all(24),
image: image, image: image,
); );
@ -303,8 +303,8 @@ final spritesheet = SpriteSheet(
); );
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
AnimationParticle( particle: AnimationParticle(
animation: spritesheet.createAnimation(0, stepTime: 0.1), animation: spritesheet.createAnimation(0, stepTime: 0.1),
); );
), ),
@ -322,8 +322,8 @@ to the `game` directly, without the `Particle` in the middle.
final longLivingRect = RectComponent(); final longLivingRect = RectComponent();
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
ComponentParticle( particle: ComponentParticle(
component: longLivingRect component: longLivingRect
); );
), ),
@ -332,7 +332,7 @@ game.add(
class RectComponent extends Component { class RectComponent extends Component {
void render(Canvas c) { void render(Canvas c) {
c.drawRect( c.drawRect(
Rect.fromCenter(center: Offset.zero, width: 100, height: 100), Rect.fromCenter(center: Offset.zero, width: 100, height: 100),
Paint()..color = Colors.red Paint()..color = Colors.red
); );
} }
@ -357,13 +357,13 @@ import 'package:flame_flare/flame_flare.dart';
// Within your game or component's `onLoad` method // Within your game or component's `onLoad` method
const flareSize = 32.0; const flareSize = 32.0;
final flareAnimation = FlareActorAnimation('assets/sparkle.flr'); final flareAnimation = FlareActorAnimation('assets/sparkle.flr');
flareAnimation.width = flareSize; flareAnimation.width = flareSize;
flareAnimation.height = flareSize; flareAnimation.height = flareSize;
// Somewhere in game // Somewhere in game
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
FlareParticle(flare: flareAnimation), particle: FlareParticle(flare: flareAnimation),
), ),
); );
``` ```
@ -380,9 +380,9 @@ on each frame to perform necessary computations and render something to the `Can
```dart ```dart
game.add( game.add(
ParticleComponent( ParticleSystemComponent(
// Renders a circle which gradually changes its color and size during the particle lifespan. // Renders a circle which gradually changes its color and size during the particle lifespan.
ComputedParticle( particle: ComputedParticle(
renderer: (canvas, particle) => canvas.drawCircle( renderer: (canvas, particle) => canvas.drawCircle(
Offset.zero, Offset.zero,
particle.progress * 10, particle.progress * 10,

View File

@ -96,8 +96,8 @@ class ParticlesExample extends FlameGame with FPSCounter {
add( add(
// Bind all the particles to a [Component] update // Bind all the particles to a [Component] update
// lifecycle from the [FlameGame]. // lifecycle from the [FlameGame].
ParticleComponent( ParticleSystemComponent(
TranslatedParticle( particle: TranslatedParticle(
lifespan: 1, lifespan: 1,
offset: cellCenter, offset: cellCenter,
child: particle, child: particle,

View File

@ -0,0 +1,59 @@
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flame/particles.dart';
import 'package:flutter/material.dart';
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';
final random = Random();
final Tween<double> noise = Tween(begin: -1, end: 1);
final ColorTween colorTween;
final double zoom;
ParticlesInteractiveExample({
required Color from,
required Color to,
required this.zoom,
}) : colorTween = ColorTween(begin: from, end: to);
@override
Future<void> onLoad() async {
camera.followVector2(Vector2.zero());
camera.zoom = zoom;
}
@override
void onPanUpdate(DragUpdateInfo info) {
add(
ParticleSystemComponent(
position: info.eventPosition.game,
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())!,
),
);
},
),
),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:dashbook/dashbook.dart'; import 'package:dashbook/dashbook.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import '../../commons/commons.dart'; import '../../commons/commons.dart';
import 'flip_sprite_example.dart'; import 'flip_sprite_example.dart';
@ -7,6 +8,7 @@ import 'isometric_tile_map_example.dart';
import 'layers_example.dart'; import 'layers_example.dart';
import 'nine_tile_box_example.dart'; import 'nine_tile_box_example.dart';
import 'particles_example.dart'; import 'particles_example.dart';
import 'particles_interactive_example.dart';
import 'text_example.dart'; import 'text_example.dart';
void addRenderingStories(Dashbook dashbook) { void addRenderingStories(Dashbook dashbook) {
@ -46,5 +48,17 @@ void addRenderingStories(Dashbook dashbook) {
(_) => GameWidget(game: ParticlesExample()), (_) => GameWidget(game: ParticlesExample()),
codeLink: baseLink('rendering/particles_example.dart'), codeLink: baseLink('rendering/particles_example.dart'),
info: ParticlesExample.description, info: ParticlesExample.description,
)
..add(
'Particles (Interactive)',
(context) => GameWidget(
game: ParticlesInteractiveExample(
from: context.colorProperty('From color', Colors.pink),
to: context.colorProperty('To color', Colors.blue),
zoom: context.numberProperty('Zoom', 1),
),
),
codeLink: baseLink('rendering/particles_interactive_example.dart'),
info: ParticlesInteractiveExample.description,
); );
} }

View File

@ -18,6 +18,7 @@ export 'src/components/mixins/tappable.dart';
export 'src/components/nine_tile_box_component.dart'; export 'src/components/nine_tile_box_component.dart';
export 'src/components/parallax_component.dart'; export 'src/components/parallax_component.dart';
export 'src/components/particle_component.dart'; export 'src/components/particle_component.dart';
export 'src/components/particle_system_component.dart';
export 'src/components/position_component.dart'; export 'src/components/position_component.dart';
export 'src/components/position_type.dart'; export 'src/components/position_type.dart';
export 'src/components/sprite_animation_component.dart'; export 'src/components/sprite_animation_component.dart';

View File

@ -7,6 +7,7 @@ import 'component.dart';
/// to a [Component] tree. Could be added either to FlameGame /// to a [Component] tree. Could be added either to FlameGame
/// or an implementation of [Component]. /// or an implementation of [Component].
/// Proxies [Component] lifecycle hooks to nested [Particle]. /// Proxies [Component] lifecycle hooks to nested [Particle].
@Deprecated('Will be removed after v1.1, use ParticleSystemComponent instead')
class ParticleComponent extends Component { class ParticleComponent extends Component {
Particle particle; Particle particle;

View File

@ -0,0 +1,53 @@
import 'dart:ui';
import '../../components.dart';
import '../../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.
/// {endtempalte}
class ParticleSystemComponent extends PositionComponent {
Particle? particle;
/// {@macro particle_system_component}
ParticleSystemComponent({
this.particle,
Vector2? position,
Vector2? size,
Vector2? scale,
double? angle,
Anchor? anchor,
int? priority,
}) : super(
position: position,
size: size,
scale: scale,
angle: angle,
anchor: anchor,
priority: priority,
);
/// 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();
}
}
}

View File

@ -0,0 +1,53 @@
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.shouldRemove, isTrue);
},
);
});
}