feat: particles

This commit is contained in:
Ivan Cherepanov
2019-11-25 17:58:16 +03:00
parent bec146afa5
commit 38e3685cda
20 changed files with 982 additions and 0 deletions

70
doc/examples/particles/.gitignore vendored Normal file
View File

@ -0,0 +1,70 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# Visual Studio Code related
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.packages
.pub-cache/
.pub/
/build/
# Android related
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages

View File

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 4c64b715d9a677922ef02f2643211a6282926eb5
channel: dev
project_type: app

View File

@ -0,0 +1,4 @@
# render_flip
A Flame game showcasing how to use render flipping on a `PositionComponent`
to control the rendered direction.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -0,0 +1,361 @@
import 'dart:async';
import 'dart:math';
import 'package:flame/components/particle_component.dart';
import 'package:flame/components/particles/circle_particle.dart';
import 'package:flame/components/particles/moving_particle.dart';
import 'package:flame/components/particles/translated_particle.dart';
import 'package:flame/components/particles/computed_particle.dart';
import 'package:flame/components/particles/image_particle.dart';
import 'package:flame/components/particles/rotating_particle.dart';
import 'package:flame/components/particles/accelerated_particle.dart';
import 'package:flame/components/particles/paint_particle.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() async {
Size gameSize;
WidgetsFlutterBinding.ensureInitialized();
await Future.wait([
Flame.util.initialDimensions().then((size) => gameSize = size),
Flame.images.loadAll(const ['zap.png']),
]);
final game = MyGame(gameSize);
runApp(game.widget);
}
class MyGame extends BaseGame {
/// Defines dimensions of the sample
/// grid to be displayed on the screen,
/// 5x5 in this particular case
static const gridSize = 5;
static const steps = 5;
Offset cellSize;
Offset halfCellSize;
Random rnd = Random();
StepTween steppedTween = StepTween(begin: 0, end: 5);
MyGame(Size screenSize) {
size = screenSize;
cellSize = Offset(size.width / gridSize, size.height / gridSize);
halfCellSize = cellSize * .5;
// Spawn new particles every second
Timer.periodic(const Duration(seconds: 1), (_) => spawnParticles());
}
/// 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(),
steppedComputedParticle(),
reuseParticles(),
imageParticle(),
reuseImageParticle(),
rotatingImage(),
acceleratedParticles(),
paintParticle(),
];
// 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;
final cellCenter =
cellSize.scale(col.toDouble(), row.toDouble()) + (cellSize * .5);
add(
TranslatedParticle(
lifespan: 1.0,
offset: cellCenter,
child: particle,
),
);
} while (particles.isNotEmpty);
}
/// Returns random [Offset] within a virtual
/// grid cell
Offset randomCellOffset() {
return cellSize.scale(rnd.nextDouble(), rnd.nextDouble()) - halfCellSize;
}
/// Returns random [Color] from primary swatches
/// of material palette
Color randomMaterialColor() {
return Colors.primaries[rnd.nextInt(Colors.primaries.length)];
}
/// 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 [Offset.zero]
from: const Offset(-20, -20),
to: const Offset(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: randomCellOffset(),
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.dx / 5) * i - halfCellSize.dx;
return MovingParticle(
from: Offset(currentColumn, -halfCellSize.dy),
to: Offset(currentColumn, halfCellSize.dy),
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: randomCellOffset() * .5,
child: CircleParticle(
radius: 5 + rnd.nextDouble() * 5,
paint: Paint()..color = Colors.deepOrange,
),
),
);
}
/// Same example as above, but
/// with easing, utilising [CurvedParticle] extension
Particle easedMovingParticle() {
return Particle.generate(
count: 5,
generator: (i) => MovingParticle(
curve: Curves.easeOutQuad,
to: randomCellOffset() * .5,
child: CircleParticle(
radius: 5 + rnd.nextDouble() * 5,
paint: Paint()..color = Colors.deepPurple,
),
),
);
}
/// Same example as above, but using awesome [Inverval]
/// 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: Interval(.2, .6, curve: Curves.easeInOutCubic),
to: randomCellOffset() * .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(
lifespan: 2,
renderer: (canvas, particle) => canvas.drawCircle(
Offset.zero,
particle.progress * halfCellSize.dx,
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.dx,
Paint()
..color = Color.lerp(
Colors.red,
Colors.blue,
steppedProgress,
),
);
},
);
}
/// Particle which is used in example below
Particle reusablePatricle;
/// 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() {
reusablePatricle ??= circle();
return Particle.generate(
count: 10,
generator: (i) => MovingParticle(
curve: Interval(rnd.nextDouble() * .1, rnd.nextDouble() * .8 + .1),
to: randomCellOffset() * .5,
child: reusablePatricle,
),
);
}
/// 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: const Size.square(24),
image: Flame.images.loadedFiles['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.dx / perLine;
final rowHeight = cellSize.dy / perLine;
reusableImageParticle ??= imageParticle();
return Particle.generate(
count: count,
generator: (i) => TranslatedParticle(
offset: Offset(
(i % perLine) * colWidth - halfCellSize.dx + imageSize,
(i ~/ perLine) * rowHeight - halfCellSize.dy + 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(
count: 10,
generator: (i) => AcceleratedParticle(
speed:
Offset(rnd.nextDouble() * 600 - 300, -rnd.nextDouble() * 600) * .4,
acceleration: const Offset(0, 600),
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 bad performance misusing composites.
Particle paintParticle() {
return Particle.generate(
count: 10,
generator: (i) => AcceleratedParticle(
speed:
Offset(rnd.nextDouble() * 600 - 300, -rnd.nextDouble() * 600) * .4,
acceleration: const Offset(0, 600),
child: PaintParticle(
paint: Paint()..blendMode = BlendMode.difference,
child: CircleParticle(
radius: 12.0,
paint: Paint()..color = randomMaterialColor()
),
),
),
);
}
}

View File

@ -0,0 +1,21 @@
name: particles
description: Flame sample game showcasing particle effects
version: 1.0.0
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
flame:
path: ../../../
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
assets:
- assets/images/zap.png

View File

@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility that Flutter provides. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:particles/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>particles</title>
</head>
<body>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>

View File

@ -0,0 +1,44 @@
import 'dart:ui';
import '../particle_component.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 {
Particle child;
@override
void setLifespan(double lifespan) {
super.setLifespan(lifespan);
child.setLifespan(lifespan);
}
@override
void render(Canvas c) {
child.render(c);
}
@override
void update(double t) {
super.update(t);
child.update(t);
}
}

View File

@ -0,0 +1,80 @@
import 'dart:ui';
import 'package:flame/components/particles/composed_particle.dart';
import 'package:flutter/foundation.dart';
import '../time.dart';
import 'component.dart';
/// A function which returns [Particle] when called
typedef ParticleGenerator = Particle Function(int);
/// Base class implementing common behavior for all the particles.
///
/// Intention is to follow same "Extreme Composability" style
/// as across the whole Flutter framework, so each type of particle implements
/// some particular behavior which then could be nested and combined together
/// to create specifically required experience.
abstract class Particle extends Component {
/// Generates given amount of particles,
/// combining them into one [ComposedParticle]
/// Useful for procedural particle generation.
static Particle generate({
int count = 10,
@required ParticleGenerator generator,
double lifespan,
Duration duration,
}) {
return ComposedParticle(
lifespan: lifespan,
duration: duration,
children: List<Particle>.generate(count, generator),
);
}
Timer _timer;
bool _shouldBeDestroyed = false;
Particle({
/// Particle lifespan in [Timer] format,
/// double in seconds with microsecond precision
double lifespan,
/// Another way to set lifespan, using Flutter
/// [Duration] class
Duration duration,
}) {
/// Either [double] lifespan or [Duration] duration,
/// defaulting to 500 milliseconds of life (or .5, in [Timer] double)
lifespan = lifespan ??
(duration ?? const Duration(milliseconds: 500)).inMicroseconds /
Duration.microsecondsPerSecond;
setLifespan(lifespan);
}
@override
bool destroy() => _shouldBeDestroyed;
double get progress => _timer.progress;
@override
void render(Canvas canvas) {
// Do nothing by default
}
@override
void update(double dt) {
_timer.update(dt);
if (_timer.progress >= 1) {
_shouldBeDestroyed = true;
}
}
void setLifespan(double lifespan) {
_timer?.stop();
_timer = Timer(lifespan);
_timer.start();
}
}

View File

@ -0,0 +1,46 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../mixins/single_child_particle.dart';
import '../particle_component.dart';
import 'curved_particle.dart';
/// A particle serves as a container for basic
/// acceleration physics.
/// [Offset] unit is logical px per second.
/// speed = Offset(0, 100) is 100 logical pixels per second, down
/// acceleration = Offset(-40, 0) will accelerate to left at rate of 40 px/s
class AcceleratedParticle extends CurvedParticle with SingleChildParticle {
@override
Particle child;
final Offset acceleration;
Offset speed;
Offset position;
AcceleratedParticle({
@required this.child,
this.acceleration = Offset.zero,
this.speed = Offset.zero,
this.position = Offset.zero,
double lifespan,
Duration duration,
}) : super(lifespan: lifespan, duration: duration);
@override
void render(Canvas canvas) {
canvas.save();
canvas.translate(position.dx, position.dy);
super.render(canvas);
canvas.restore();
}
@override
void update(double t) {
speed += acceleration * t;
position += speed * t;
super.update(t);
}
}

View File

@ -0,0 +1,29 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../../time.dart';
import '../particle_component.dart';
/// Plain circle with no other behaviors
/// Consider composing with other [Particle]
/// to achieve needed effects
class CircleParticle extends Particle {
final Paint paint;
final double radius;
CircleParticle({
@required this.paint,
this.radius = 10.0,
double lifespan,
Duration duration,
}) : super(
lifespan: lifespan,
duration: duration,
);
@override
void render(Canvas c) {
c.drawCircle(Offset.zero, radius, paint);
}
}

View File

@ -0,0 +1,43 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../particle_component.dart';
class ComposedParticle extends Particle {
final List<Particle> children;
ComposedParticle({
@required this.children,
double lifespan,
Duration duration,
}) : super(
lifespan: lifespan,
duration: duration,
);
@override
void setLifespan(double lifespan) {
super.setLifespan(lifespan);
for (var child in children) {
child.setLifespan(lifespan);
}
}
@override
void render(Canvas c) {
for (var child in children) {
child.render(c);
}
}
@override
void update(double dt) {
super.update(dt);
for (var child in children) {
child.update(dt);
}
}
}

View File

@ -0,0 +1,32 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../particle_component.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 renderign 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,
double lifespan,
Duration duration,
}) : super(
lifespan: lifespan,
duration: duration,
);
@override
void render(Canvas canvas) {
renderer(canvas, this);
}
}

View File

@ -0,0 +1,16 @@
import 'package:flutter/animation.dart';
import '../particle_component.dart';
class CurvedParticle extends Particle {
final Curve curve;
CurvedParticle({
this.curve = Curves.linear,
double lifespan,
Duration duration,
}) : super(lifespan: lifespan, duration: duration);
@override
double get progress => curve.transform(super.progress);
}

View File

@ -0,0 +1,33 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../particle_component.dart';
class ImageParticle extends Particle {
/// dart.ui [Image] to draw
Image image;
Rect src;
Rect dest;
ImageParticle({
@required this.image,
Size size,
double lifespan,
Duration duration,
}) : super(lifespan: lifespan, duration: duration) {
final srcWidth = image.width.toDouble();
final srcHeight = image.height.toDouble();
final destWidth = size?.width ?? srcWidth;
final destHeight = size?.height ?? 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());
}
}

View File

@ -0,0 +1,40 @@
import 'dart:ui';
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import '../particles/curved_particle.dart';
import '../mixins/single_child_particle.dart';
import '../particle_component.dart';
/// Statically offset given child [Particle] by given [Offset]
/// If you're loking to move the child, consider [MovingParticle]
class MovingParticle extends CurvedParticle with SingleChildParticle {
@override
Particle child;
final Offset from;
final Offset to;
MovingParticle({
@required this.child,
@required this.to,
this.from = Offset.zero,
double lifespan,
Duration duration,
Curve curve = Curves.linear,
}) : super(
lifespan: lifespan,
duration: duration,
curve: curve,
);
@override
void render(Canvas c) {
c.save();
final Offset current = Offset.lerp(from, to, progress);
c.translate(current.dx, current.dy);
super.render(c);
c.restore();
}
}

View File

@ -0,0 +1,44 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../mixins/single_child_particle.dart';
import '../particle_component.dart';
import 'curved_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),
double lifespan,
Duration duration,
}) : super(
lifespan: lifespan,
duration: duration,
);
@override
void render(Canvas canvas) {
canvas.saveLayer(bounds, paint);
super.render(canvas);
canvas.restore();
}
}

View File

@ -0,0 +1,36 @@
import 'dart:math';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../mixins/single_child_particle.dart';
import '../particle_component.dart';
import 'curved_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,
double lifespan,
Duration duration,
}) : super(lifespan: lifespan, duration: duration);
double get angle => lerpDouble(from, to, progress);
@override
void render(Canvas canvas) {
canvas.save();
canvas.rotate(angle);
super.render(canvas);
canvas.restore();
}
}

View File

@ -0,0 +1,33 @@
import 'dart:ui';
import 'package:flame/components/mixins/single_child_particle.dart';
import 'package:flutter/foundation.dart';
import '../../time.dart';
import '../particle_component.dart';
/// Statically offset given child [Particle] by given [Offset]
/// If you're loking to move the child, consider [MovingParticle]
class TranslatedParticle extends Particle with SingleChildParticle {
@override
Particle child;
Offset offset;
TranslatedParticle({
@required this.child,
@required this.offset,
double lifespan,
Duration duration,
}) : super(
lifespan: lifespan,
duration: duration,
);
@override
void render(Canvas c) {
c.save();
c.translate(offset.dx, offset.dy);
super.render(c);
c.restore();
}
}