From de963cbc862e4c5c9953203c59a8f9ec83e018fe Mon Sep 17 00:00:00 2001 From: Erick Date: Fri, 25 Mar 2022 10:19:35 -0300 Subject: [PATCH] feat: adds ball turbocharge effect (#66) * feat: adding ball boost effect * fix: lint --- .../lib/src/components/ball.dart | 26 ++++ .../lib/src/components/components.dart | 1 + .../lib/src/components/fire_effect.dart | 113 ++++++++++++++++++ .../sandbox/lib/common/common.dart | 13 +- .../sandbox/lib/common/games.dart | 74 ++++++++++++ .../sandbox/lib/common/methods.dart | 3 + .../pinball_components/sandbox/lib/main.dart | 2 + .../sandbox/lib/stories/ball/ball.dart | 28 +++-- .../lib/stories/ball/ball_booster.dart | 16 +++ .../sandbox/lib/stories/ball/basic.dart | 6 +- .../sandbox/lib/stories/effects/effects.dart | 13 ++ .../lib/stories/effects/fire_effect.dart | 46 +++++++ .../test/helpers/helpers.dart | 1 + .../test/helpers/mocks.dart | 5 + .../test/src/components/ball_test.dart | 24 ++++ .../test/src/components/fire_effect_test.dart | 55 +++++++++ 16 files changed, 403 insertions(+), 23 deletions(-) create mode 100644 packages/pinball_components/lib/src/components/fire_effect.dart create mode 100644 packages/pinball_components/sandbox/lib/common/games.dart create mode 100644 packages/pinball_components/sandbox/lib/common/methods.dart create mode 100644 packages/pinball_components/sandbox/lib/stories/ball/ball_booster.dart create mode 100644 packages/pinball_components/sandbox/lib/stories/effects/effects.dart create mode 100644 packages/pinball_components/sandbox/lib/stories/effects/fire_effect.dart create mode 100644 packages/pinball_components/test/helpers/mocks.dart create mode 100644 packages/pinball_components/test/src/components/fire_effect_test.dart diff --git a/packages/pinball_components/lib/src/components/ball.dart b/packages/pinball_components/lib/src/components/ball.dart index 674cdbf3..2ceb56d7 100644 --- a/packages/pinball_components/lib/src/components/ball.dart +++ b/packages/pinball_components/lib/src/components/ball.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:flame/components.dart'; @@ -27,6 +28,9 @@ class Ball extends BodyComponent /// The base [Color] used to tint this [Ball] final Color baseColor; + double _boostTimer = 0; + static const _boostDuration = 2.0; + @override Future onLoad() async { await super.onLoad(); @@ -69,4 +73,26 @@ class Ball extends BodyComponent void resume() { body.setType(BodyType.dynamic); } + + @override + void update(double dt) { + super.update(dt); + if (_boostTimer > 0) { + _boostTimer -= dt; + final direction = body.linearVelocity.normalized(); + final effect = FireEffect( + burstPower: _boostTimer, + direction: direction, + position: body.position, + ); + + unawaited(gameRef.add(effect)); + } + } + + /// Applies a boost on this [Ball] + void boost(Vector2 impulse) { + body.applyLinearImpulse(impulse); + _boostTimer = _boostDuration; + } } diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 677bbd0c..c1ef3e14 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -1,3 +1,4 @@ export 'ball.dart'; +export 'fire_effect.dart'; export 'initial_position.dart'; export 'layer.dart'; diff --git a/packages/pinball_components/lib/src/components/fire_effect.dart b/packages/pinball_components/lib/src/components/fire_effect.dart new file mode 100644 index 00000000..0a7cef2b --- /dev/null +++ b/packages/pinball_components/lib/src/components/fire_effect.dart @@ -0,0 +1,113 @@ +import 'dart:math' as math; + +import 'package:flame/extensions.dart'; +import 'package:flame/particles.dart'; +import 'package:flame_forge2d/flame_forge2d.dart' hide Particle; +import 'package:flutter/material.dart'; + +const _particleRadius = 0.25; + +// TODO(erickzanardo): This component could just be a ParticleComponet, +/// unfortunately there is a Particle Component is not a PositionComponent, +/// which makes it hard to be used since we have camera transformations and on +// top of that, PositionComponent has a bug inside forge 2d games +/// +/// https://github.com/flame-engine/flame/issues/1484 +/// https://github.com/flame-engine/flame/issues/1484 + +/// {@template fire_effect} +/// A [BodyComponent] which creates a fire trail effect using the given +/// parameters +/// {@endtemplate} +class FireEffect extends BodyComponent { + /// {@macro fire_effect} + FireEffect({ + required this.burstPower, + required this.position, + required this.direction, + }); + + /// A [double] value that will define how "strong" the burst of particles + /// will be + final double burstPower; + + /// The position of the burst + final Vector2 position; + + /// Which direction the burst will aim + final Vector2 direction; + late Particle _particle; + + @override + Body createBody() { + final bodyDef = BodyDef()..position = position; + + final fixtureDef = FixtureDef(CircleShape()..radius = 0)..isSensor = true; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } + + @override + Future onLoad() async { + await super.onLoad(); + + final children = [ + ...List.generate(4, (index) { + return CircleParticle( + radius: _particleRadius, + paint: Paint()..color = Colors.yellow.darken((index + 1) / 4), + ); + }), + ...List.generate(4, (index) { + return CircleParticle( + radius: _particleRadius, + paint: Paint()..color = Colors.red.darken((index + 1) / 4), + ); + }), + ...List.generate(4, (index) { + return CircleParticle( + radius: _particleRadius, + paint: Paint()..color = Colors.orange.darken((index + 1) / 4), + ); + }), + ]; + final rng = math.Random(); + final spreadTween = Tween(begin: -0.2, end: 0.2); + + _particle = Particle.generate( + count: (rng.nextDouble() * (burstPower * 10)).toInt(), + generator: (_) { + final spread = Vector2( + spreadTween.transform(rng.nextDouble()), + spreadTween.transform(rng.nextDouble()), + ); + final finalDirection = Vector2(direction.x, -direction.y) + spread; + final speed = finalDirection * (burstPower * 20); + + return AcceleratedParticle( + lifespan: 5 / burstPower, + position: Vector2.zero(), + speed: speed, + child: children[rng.nextInt(children.length)], + ); + }, + ); + } + + @override + void update(double dt) { + super.update(dt); + _particle.update(dt); + + if (_particle.shouldRemove) { + removeFromParent(); + } + } + + @override + void render(Canvas canvas) { + super.render(canvas); + + _particle.render(canvas); + } +} diff --git a/packages/pinball_components/sandbox/lib/common/common.dart b/packages/pinball_components/sandbox/lib/common/common.dart index b7ee5a4a..578c9b38 100644 --- a/packages/pinball_components/sandbox/lib/common/common.dart +++ b/packages/pinball_components/sandbox/lib/common/common.dart @@ -1,11 +1,2 @@ -import 'package:flame_forge2d/flame_forge2d.dart'; - -String buildSourceLink(String path) { - return 'https://github.com/VGVentures/pinball/tree/main/packages/pinball_components/sandbox/lib/stories/$path'; -} - -class BasicGame extends Forge2DGame { - BasicGame() { - images.prefix = ''; - } -} +export 'games.dart'; +export 'methods.dart'; diff --git a/packages/pinball_components/sandbox/lib/common/games.dart b/packages/pinball_components/sandbox/lib/common/games.dart new file mode 100644 index 00000000..bce1ff90 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/common/games.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/input.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; + +class BasicGame extends Forge2DGame { + BasicGame() { + images.prefix = ''; + } +} + +abstract class LineGame extends BasicGame with PanDetector { + Vector2? _lineEnd; + + @override + Future onLoad() async { + await super.onLoad(); + + camera.followVector2(Vector2.zero()); + unawaited(add(_PreviewLine())); + } + + @override + void onPanStart(DragStartInfo info) { + _lineEnd = info.eventPosition.game; + } + + @override + void onPanUpdate(DragUpdateInfo info) { + _lineEnd = info.eventPosition.game; + } + + @override + void onPanEnd(DragEndInfo info) { + if (_lineEnd != null) { + final line = _lineEnd! - Vector2.zero(); + onLine(line); + _lineEnd = null; + } + } + + void onLine(Vector2 line); +} + +class _PreviewLine extends PositionComponent with HasGameRef { + static final _previewLinePaint = Paint() + ..color = Colors.pink + ..strokeWidth = 0.2 + ..style = PaintingStyle.stroke; + + Vector2? lineEnd; + + @override + void update(double dt) { + super.update(dt); + + lineEnd = gameRef._lineEnd?.clone()?..multiply(Vector2(1, -1)); + } + + @override + void render(Canvas canvas) { + super.render(canvas); + + if (lineEnd != null) { + canvas.drawLine( + Vector2.zero().toOffset(), + lineEnd!.toOffset(), + _previewLinePaint, + ); + } + } +} diff --git a/packages/pinball_components/sandbox/lib/common/methods.dart b/packages/pinball_components/sandbox/lib/common/methods.dart new file mode 100644 index 00000000..35198922 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/common/methods.dart @@ -0,0 +1,3 @@ +String buildSourceLink(String path) { + return 'https://github.com/VGVentures/pinball/tree/main/packages/pinball_components/sandbox/lib/stories/$path'; +} diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index 0cfd6f7f..dd6aeafe 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -6,11 +6,13 @@ // https://opensource.org/licenses/MIT. import 'package:dashbook/dashbook.dart'; import 'package:flutter/material.dart'; +import 'package:sandbox/stories/effects/effects.dart'; import 'package:sandbox/stories/stories.dart'; void main() { final dashbook = Dashbook(theme: ThemeData.dark()); addBallStories(dashbook); + addEffectsStories(dashbook); runApp(dashbook); } diff --git a/packages/pinball_components/sandbox/lib/stories/ball/ball.dart b/packages/pinball_components/sandbox/lib/stories/ball/ball.dart index f8e49a57..35b29499 100644 --- a/packages/pinball_components/sandbox/lib/stories/ball/ball.dart +++ b/packages/pinball_components/sandbox/lib/stories/ball/ball.dart @@ -2,17 +2,27 @@ import 'package:dashbook/dashbook.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/ball/ball_booster.dart'; import 'package:sandbox/stories/ball/basic.dart'; void addBallStories(Dashbook dashbook) { - dashbook.storiesOf('Ball').add( - 'Basic', - (context) => GameWidget( - game: BasicBallGame( - color: context.colorProperty('color', Colors.blue), - ), + dashbook.storiesOf('Ball') + ..add( + 'Basic', + (context) => GameWidget( + game: BasicBallGame( + color: context.colorProperty('color', Colors.blue), ), - codeLink: buildSourceLink('ball/basic.dart'), - info: BasicBallGame.info, - ); + ), + codeLink: buildSourceLink('ball/basic.dart'), + info: BasicBallGame.info, + ) + ..add( + 'Booster', + (context) => GameWidget( + game: BallBoosterExample(), + ), + codeLink: buildSourceLink('ball/ball_booster.dart'), + info: BallBoosterExample.info, + ); } diff --git a/packages/pinball_components/sandbox/lib/stories/ball/ball_booster.dart b/packages/pinball_components/sandbox/lib/stories/ball/ball_booster.dart new file mode 100644 index 00000000..9f78953a --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/ball/ball_booster.dart @@ -0,0 +1,16 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; + +class BallBoosterExample extends LineGame { + static const info = ''; + + @override + void onLine(Vector2 line) { + final ball = Ball(baseColor: Colors.transparent); + add(ball); + + ball.mounted.then((value) => ball.boost(line * -1 * 20)); + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/ball/basic.dart b/packages/pinball_components/sandbox/lib/stories/ball/basic.dart index 78948666..f133ee3f 100644 --- a/packages/pinball_components/sandbox/lib/stories/ball/basic.dart +++ b/packages/pinball_components/sandbox/lib/stories/ball/basic.dart @@ -4,7 +4,7 @@ import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/common/common.dart'; class BasicBallGame extends BasicGame with TapDetector { - BasicBallGame({ required this.color }); + BasicBallGame({required this.color}); static const info = ''' Basic example of how a Ball works, tap anywhere on the @@ -15,8 +15,8 @@ class BasicBallGame extends BasicGame with TapDetector { @override void onTapUp(TapUpInfo info) { - add(Ball(baseColor: color) - ..initialPosition = info.eventPosition.game, + add( + Ball(baseColor: color)..initialPosition = info.eventPosition.game, ); } } diff --git a/packages/pinball_components/sandbox/lib/stories/effects/effects.dart b/packages/pinball_components/sandbox/lib/stories/effects/effects.dart new file mode 100644 index 00000000..3a89c73b --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/effects/effects.dart @@ -0,0 +1,13 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/effects/fire_effect.dart'; + +void addEffectsStories(Dashbook dashbook) { + dashbook.storiesOf('Effects').add( + 'Fire Effect', + (context) => GameWidget(game: FireEffectExample()), + codeLink: buildSourceLink('effects/fire_effect.dart'), + info: FireEffectExample.info, + ); +} diff --git a/packages/pinball_components/sandbox/lib/stories/effects/fire_effect.dart b/packages/pinball_components/sandbox/lib/stories/effects/fire_effect.dart new file mode 100644 index 00000000..9f066952 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/effects/fire_effect.dart @@ -0,0 +1,46 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; + +class FireEffectExample extends LineGame { + static const info = 'Demonstrate the fire trail effect ' + 'drag a line to define the trail direction'; + + @override + void onLine(Vector2 line) { + add(_EffectEmitter(line)); + } +} + +class _EffectEmitter extends Component { + _EffectEmitter(this.line) { + _direction = line.normalized(); + _force = line.length; + } + + static const _timerLimit = 2.0; + var _timer = _timerLimit; + + final Vector2 line; + + late Vector2 _direction; + late double _force; + + @override + void update(double dt) { + super.update(dt); + + if (_timer > 0) { + add( + FireEffect( + burstPower: (_timer / _timerLimit) * _force, + position: Vector2.zero(), + direction: _direction, + ), + ); + _timer -= dt; + } else { + removeFromParent(); + } + } +} diff --git a/packages/pinball_components/test/helpers/helpers.dart b/packages/pinball_components/test/helpers/helpers.dart index a8b9f7ff..312f42ec 100644 --- a/packages/pinball_components/test/helpers/helpers.dart +++ b/packages/pinball_components/test/helpers/helpers.dart @@ -1 +1,2 @@ +export 'mocks.dart'; export 'test_game.dart'; diff --git a/packages/pinball_components/test/helpers/mocks.dart b/packages/pinball_components/test/helpers/mocks.dart new file mode 100644 index 00000000..67df9918 --- /dev/null +++ b/packages/pinball_components/test/helpers/mocks.dart @@ -0,0 +1,5 @@ +import 'dart:ui'; + +import 'package:mocktail/mocktail.dart'; + +class MockCanvas extends Mock implements Canvas {} diff --git a/packages/pinball_components/test/src/components/ball_test.dart b/packages/pinball_components/test/src/components/ball_test.dart index 14a3de35..a9eb05ad 100644 --- a/packages/pinball_components/test/src/components/ball_test.dart +++ b/packages/pinball_components/test/src/components/ball_test.dart @@ -158,5 +158,29 @@ void main() { ); }); }); + + group('boost', () { + flameTester.test('applies an impulse to the ball', (game) async { + final ball = Ball(baseColor: Colors.blue); + await game.ensureAdd(ball); + + expect(ball.body.linearVelocity, equals(Vector2.zero())); + + ball.boost(Vector2.all(10)); + expect(ball.body.linearVelocity.x, greaterThan(0)); + expect(ball.body.linearVelocity.y, greaterThan(0)); + }); + + flameTester.test('adds fire effect components to the game', (game) async { + final ball = Ball(baseColor: Colors.blue); + await game.ensureAdd(ball); + + ball.boost(Vector2.all(10)); + game.update(0); + await game.ready(); + + expect(game.children.whereType().length, greaterThan(0)); + }); + }); }); } diff --git a/packages/pinball_components/test/src/components/fire_effect_test.dart b/packages/pinball_components/test/src/components/fire_effect_test.dart new file mode 100644 index 00000000..bc6baa4b --- /dev/null +++ b/packages/pinball_components/test/src/components/fire_effect_test.dart @@ -0,0 +1,55 @@ +// ignore_for_file: cascade_invocations + +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + setUpAll(() { + registerFallbackValue(Offset.zero); + registerFallbackValue(Paint()); + }); + + group('FireEffect', () { + flameTester.test('is removed once its particles are done', (game) async { + await game.ensureAdd( + FireEffect( + burstPower: 1, + position: Vector2.zero(), + direction: Vector2.all(2), + ), + ); + await game.ready(); + expect(game.children.whereType().length, equals(1)); + game.update(5); + + await game.ready(); + expect(game.children.whereType().length, equals(0)); + }); + + flameTester.test('render circles on the canvas', (game) async { + final effect = FireEffect( + burstPower: 1, + position: Vector2.zero(), + direction: Vector2.all(2), + ); + await game.ensureAdd(effect); + await game.ready(); + + final canvas = MockCanvas(); + effect.render(canvas); + + verify(() => canvas.drawCircle(any(), any(), any())) + .called(greaterThan(0)); + }); + }); +}