Files
flame/examples/lib/stories/rendering/particles_example.dart
Luan Nico b79fee0ae2 chore: Update min Dart constraint to 3.8 (#3676)
Update min Dart constraint to 3.8, which will enable us to use the
fancier collection literals.

This requires bumping the min Flutter version as well:

<img width="1892" height="1122" alt="image"
src="https://github.com/user-attachments/assets/7c7b07fc-4d96-4987-824d-9a7133ecfb85"
/>
2025-08-10 12:42:31 -04:00

566 lines
16 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart' hide Timer;
import 'package:flame/game.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.
''';
/// 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;
@override
Future<void> 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 = <Particle>[
circle(),
smallWhiteCircle(),
movingParticle(),
randomMovingParticle(),
alignedMovingParticles(),
easedMovingParticle(),
intervalMovingParticle(),
computedParticle(),
chainingBehaviors(),
steppedComputedParticle(),
reuseParticles(),
imageParticle(),
reuseImageParticle(),
rotatingImage(),
acceleratedParticles(),
paintParticle(),
spriteParticle(),
animationParticle(),
fireworkParticle(),
componentParticle(),
];
// 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;
add(
// Bind all the particles to a [Component] update
// lifecycle from the [FlameGame].
ParticleSystemComponent(
particle: TranslatedParticle(
lifespan: 1,
offset: cellCenter,
child: particle,
),
),
);
} while (particles.isNotEmpty);
}
/// Simple static circle, doesn't move or
/// change any of its attributes
Particle circle() {
return CircleParticle(
paint: Paint()..color = Colors.white10,
);
}
/// This one will is a bit smaller,
/// and a bit less transparent
Particle smallWhiteCircle() {
return CircleParticle(
radius: 5.0,
paint: Paint()..color = Colors.white,
);
}
/// 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),
);
}
/// [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,
),
);
}
/// 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,
),
);
},
);
}
/// 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,
),
),
);
}
/// 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,
),
),
);
}
/// 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,
),
),
);
}
/// 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,
)!,
),
);
}
/// 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;
canvas.drawCircle(
Offset.zero,
(1 - steppedProgress) * halfCellSize.x,
Paint()
..color = Color.lerp(
Colors.red,
Colors.blue,
steppedProgress,
)!,
);
},
);
}
/// 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!,
),
);
}
/// 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!,
),
);
}
/// [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),
),
);
}
/// [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],
),
),
),
);
}
/// [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,
),
);
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<T>(List<T> 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<Sprite>.generate(frames, spriteSheet.getSpriteById);
return SpriteAnimation.spriteList(sprites, stepTime: 0.1);
}
}
Future<FlameGame> loadGame() async {
WidgetsFlutterBinding.ensureInitialized();
return ParticlesExample();
}
/// 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;
}
}
/// 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 = <Color>[
Colors.green,
Colors.orange,
Colors.red,
];
final Paint _paint = Paint();
@override
void onMount() {
colorChangeTimer.start();
}
@override
void render(Canvas canvas) {
canvas.drawRect(rect, _paint..color = currentColor);
}
@override
void update(double dt) {
colorChangeTimer.update(dt);
}
Color get currentColor {
return colors[(colorChangeTimer.progress * colors.length).toInt()];
}
}