diff --git a/lib/game/behaviors/behaviors.dart b/lib/game/behaviors/behaviors.dart index 301bc61e..5900f2b3 100644 --- a/lib/game/behaviors/behaviors.dart +++ b/lib/game/behaviors/behaviors.dart @@ -1,5 +1,6 @@ export 'ball_spawning_behavior.dart'; export 'ball_theming_behavior.dart'; +export 'bonus_ball_spawning_behavior.dart'; export 'bonus_noise_behavior.dart'; export 'bumper_noise_behavior.dart'; export 'camera_focusing_behavior.dart'; diff --git a/lib/game/behaviors/bonus_ball_spawning_behavior.dart b/lib/game/behaviors/bonus_ball_spawning_behavior.dart new file mode 100644 index 00000000..26fe423d --- /dev/null +++ b/lib/game/behaviors/bonus_ball_spawning_behavior.dart @@ -0,0 +1,30 @@ +import 'package:flame/components.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template bonus_ball_spawning_behavior} +/// After a duration, spawns a bonus ball from the [DinoWalls] and boosts it +/// into the middle of the board. +/// {@endtemplate} +class BonusBallSpawningBehavior extends TimerComponent with HasGameRef { + /// {@macro bonus_ball_spawning_behavior} + BonusBallSpawningBehavior() + : super( + period: 5, + removeOnFinish: true, + ); + + @override + void onTick() { + final characterTheme = readBloc() + .state + .characterTheme; + gameRef.descendants().whereType().single.add( + Ball(assetPath: characterTheme.ball.keyName) + ..add(BallImpulsingBehavior(impulse: Vector2(-40, 0))) + ..initialPosition = Vector2(29.2, -24.5) + ..zIndex = ZIndexes.ballOnBoard, + ); + } +} diff --git a/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart b/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart index c9cd083f..3c4ef02a 100644 --- a/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart +++ b/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart @@ -1,7 +1,7 @@ import 'package:flame/components.dart'; import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; @@ -22,7 +22,6 @@ class FlutterForestBonusBehavior extends Component final bumpers = parent.children.whereType(); final signpost = parent.firstChild()!; final animatronic = parent.firstChild()!; - final canvas = gameRef.descendants().whereType().single; for (final bumper in bumpers) { bumper.bloc.stream.listen((state) { @@ -38,15 +37,7 @@ class FlutterForestBonusBehavior extends Component if (signpost.bloc.isFullyProgressed()) { bloc.add(const BonusActivated(GameBonus.dashNest)); - final characterTheme = - readBloc() - .state - .characterTheme; - canvas.add( - Ball(assetPath: characterTheme.ball.keyName) - ..initialPosition = Vector2(29.2, -24.5) - ..zIndex = ZIndexes.ballOnBoard, - ); + add(BonusBallSpawningBehavior()); animatronic.playing = true; signpost.bloc.onProgressed(); } diff --git a/packages/pinball_components/assets/images/boundary/bottom.png b/packages/pinball_components/assets/images/boundary/bottom.png index 806f7051..523e6156 100644 Binary files a/packages/pinball_components/assets/images/boundary/bottom.png and b/packages/pinball_components/assets/images/boundary/bottom.png differ diff --git a/packages/pinball_components/lib/src/components/ball/behaviors/ball_impulsing_behavior.dart b/packages/pinball_components/lib/src/components/ball/behaviors/ball_impulsing_behavior.dart new file mode 100644 index 00000000..d875ef7c --- /dev/null +++ b/packages/pinball_components/lib/src/components/ball/behaviors/ball_impulsing_behavior.dart @@ -0,0 +1,22 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template ball_impulsing_behavior} +/// Impulses the [Ball] in a given direction. +/// {@endtemplate} +class BallImpulsingBehavior extends Component with ParentIsA { + /// {@macro ball_impulsing_behavior} + BallImpulsingBehavior({ + required Vector2 impulse, + }) : _impulse = impulse; + + final Vector2 _impulse; + + @override + Future onLoad() async { + await super.onLoad(); + parent.body.linearVelocity = _impulse; + shouldRemove = true; + } +} diff --git a/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart index 1068a20e..d2be36a9 100644 --- a/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart +++ b/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart @@ -1,3 +1,4 @@ export 'ball_gravitating_behavior.dart'; +export 'ball_impulsing_behavior.dart'; export 'ball_scaling_behavior.dart'; export 'ball_turbo_charging_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart index 0796be92..ea7ed2e7 100644 --- a/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart @@ -42,7 +42,7 @@ class SpaceshipRamp extends Component { _SpaceshipRampBackground(), _SpaceshipRampBoardOpening()..initialPosition = Vector2(3.4, -39.5), _SpaceshipRampForegroundRailing(), - _SpaceshipRampBase()..initialPosition = Vector2(3.4, -42.5), + SpaceshipRampBase()..initialPosition = Vector2(3.4, -42.5), _SpaceshipRampBackgroundRailingSpriteComponent(), SpaceshipRampArrowSpriteComponent( current: bloc.state.hits, @@ -255,9 +255,14 @@ class _SpaceshipRampBoardOpening extends BodyComponent _SpaceshipRampBoardOpeningSpriteComponent(), LayerContactBehavior(layer: Layer.spaceshipEntranceRamp) ..applyTo(['inside']), - LayerContactBehavior(layer: Layer.board)..applyTo(['outside']), - ZIndexContactBehavior(zIndex: ZIndexes.ballOnBoard) - ..applyTo(['outside']), + LayerContactBehavior( + layer: Layer.board, + onBegin: false, + )..applyTo(['outside']), + ZIndexContactBehavior( + zIndex: ZIndexes.ballOnBoard, + onBegin: false, + )..applyTo(['outside']), ZIndexContactBehavior(zIndex: ZIndexes.ballOnSpaceshipRamp) ..applyTo(['middle', 'inside']), ], @@ -426,9 +431,19 @@ class _SpaceshipRampForegroundRailingSpriteComponent extends SpriteComponent } } -class _SpaceshipRampBase extends BodyComponent with Layered, InitialPosition { - _SpaceshipRampBase() : super(renderBody: false) { - layer = Layer.board; +@visibleForTesting +class SpaceshipRampBase extends BodyComponent + with InitialPosition, ContactCallbacks { + SpaceshipRampBase() : super(renderBody: false); + + @override + void preSolve(Object other, Contact contact, Manifold oldManifold) { + super.preSolve(other, contact, oldManifold); + if (other is! Layered) return; + // Although, the Layer should already be taking care of the contact + // filtering, this is to ensure the ball doesn't collide with the ramp base + // when the filtering is calculated on different time steps. + contact.setEnabled(other.layer == Layer.board); } @override @@ -441,7 +456,7 @@ class _SpaceshipRampBase extends BodyComponent with Layered, InitialPosition { Vector2(4.1, 1.5), ], ); - final bodyDef = BodyDef(position: initialPosition); + final bodyDef = BodyDef(position: initialPosition, userData: this); return world.createBody(bodyDef)..createFixtureFromShape(shape); } } diff --git a/packages/pinball_components/test/src/components/ball/behaviors/ball_implusing_behavior_test.dart b/packages/pinball_components/test/src/components/ball/behaviors/ball_implusing_behavior_test.dart new file mode 100644 index 00000000..53ab4553 --- /dev/null +++ b/packages/pinball_components/test/src/components/ball/behaviors/ball_implusing_behavior_test.dart @@ -0,0 +1,53 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'BallImpulsingBehavior', + () { + final asset = theme.Assets.images.dash.ball.keyName; + final flameTester = FlameTester(() => TestGame([asset])); + + test('can be instantiated', () { + expect( + BallImpulsingBehavior(impulse: Vector2.zero()), + isA(), + ); + }); + + flameTester.test( + 'impulses the ball with the given velocity when loaded ' + 'and then removes itself', + (game) async { + final ball = Ball.test(); + await game.ensureAdd(ball); + final impulse = Vector2.all(1); + final behavior = BallImpulsingBehavior(impulse: impulse); + await ball.ensureAdd(behavior); + + expect( + ball.body.linearVelocity.x, + equals(impulse.x), + ); + expect( + ball.body.linearVelocity.y, + equals(impulse.y), + ); + expect( + game.descendants().whereType().isEmpty, + isTrue, + ); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/golden/boundaries.png b/packages/pinball_components/test/src/components/golden/boundaries.png index 68f57a86..e8075f63 100644 Binary files a/packages/pinball_components/test/src/components/golden/boundaries.png and b/packages/pinball_components/test/src/components/golden/boundaries.png differ diff --git a/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart index b74cfb88..7bd18aeb 100644 --- a/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart +++ b/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart @@ -2,6 +2,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -12,6 +13,12 @@ import '../../../helpers/helpers.dart'; class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} +class _MockBall extends Mock implements Ball {} + +class _MockContact extends Mock implements Contact {} + +class _MockManifold extends Mock implements Manifold {} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); final assets = [ @@ -275,4 +282,46 @@ void main() { }); }); }); + + group('SpaceshipRampBase', () { + test('can be instantiated', () { + expect(SpaceshipRampBase(), isA()); + }); + + flameTester.test('can be loaded', (game) async { + final component = SpaceshipRampBase(); + await game.ensureAdd(component); + expect(game.children, contains(component)); + }); + + flameTester.test( + 'postSolves disables contact when ball is not on Layer.board', + (game) async { + final ball = _MockBall(); + final contact = _MockContact(); + when(() => ball.layer).thenReturn(Layer.spaceshipEntranceRamp); + final component = SpaceshipRampBase(); + await game.ensureAdd(component); + + component.preSolve(ball, contact, _MockManifold()); + + verify(() => contact.setEnabled(false)).called(1); + }, + ); + + flameTester.test( + 'postSolves enables contact when ball is on Layer.board', + (game) async { + final ball = _MockBall(); + final contact = _MockContact(); + when(() => ball.layer).thenReturn(Layer.board); + final component = SpaceshipRampBase(); + await game.ensureAdd(component); + + component.preSolve(ball, contact, _MockManifold()); + + verify(() => contact.setEnabled(true)).called(1); + }, + ); + }); } diff --git a/packages/pinball_flame/lib/src/behaviors/layer_contact_behavior.dart b/packages/pinball_flame/lib/src/behaviors/layer_contact_behavior.dart index a73b94a5..ef475bc9 100644 --- a/packages/pinball_flame/lib/src/behaviors/layer_contact_behavior.dart +++ b/packages/pinball_flame/lib/src/behaviors/layer_contact_behavior.dart @@ -6,15 +6,20 @@ import 'package:pinball_flame/pinball_flame.dart'; /// {@endtemplate} class LayerContactBehavior extends ContactBehavior { /// {@macro layer_contact_behavior} - LayerContactBehavior({required Layer layer}) : _layer = layer; - - final Layer _layer; + LayerContactBehavior({ + required Layer layer, + bool onBegin = true, + }) { + if (onBegin) { + onBeginContact = (other, _) => _changeLayer(other, layer); + } else { + onEndContact = (other, _) => _changeLayer(other, layer); + } + } - @override - void beginContact(Object other, Contact contact) { - super.beginContact(other, contact); + void _changeLayer(Object other, Layer layer) { if (other is! Layered) return; - if (other.layer == _layer) return; - other.layer = _layer; + if (other.layer == layer) return; + other.layer = layer; } } diff --git a/packages/pinball_flame/lib/src/behaviors/z_index_contact_behavior.dart b/packages/pinball_flame/lib/src/behaviors/z_index_contact_behavior.dart index ea9bfcad..c763e2cf 100644 --- a/packages/pinball_flame/lib/src/behaviors/z_index_contact_behavior.dart +++ b/packages/pinball_flame/lib/src/behaviors/z_index_contact_behavior.dart @@ -6,15 +6,20 @@ import 'package:pinball_flame/pinball_flame.dart'; /// {@endtemplate} class ZIndexContactBehavior extends ContactBehavior { /// {@macro layer_contact_behavior} - ZIndexContactBehavior({required int zIndex}) : _zIndex = zIndex; - - final int _zIndex; + ZIndexContactBehavior({ + required int zIndex, + bool onBegin = true, + }) { + if (onBegin) { + onBeginContact = (other, _) => _changeZIndex(other, zIndex); + } else { + onEndContact = (other, _) => _changeZIndex(other, zIndex); + } + } - @override - void beginContact(Object other, Contact contact) { - super.beginContact(other, contact); + void _changeZIndex(Object other, int zIndex) { if (other is! ZIndex) return; - if (other.zIndex == _zIndex) return; - other.zIndex = _zIndex; + if (other.zIndex == zIndex) return; + other.zIndex = zIndex; } } diff --git a/packages/pinball_flame/test/src/behaviors/layer_contact_behavior_test.dart b/packages/pinball_flame/test/src/behaviors/layer_contact_behavior_test.dart index 49040977..d4b7ba18 100644 --- a/packages/pinball_flame/test/src/behaviors/layer_contact_behavior_test.dart +++ b/packages/pinball_flame/test/src/behaviors/layer_contact_behavior_test.dart @@ -56,5 +56,23 @@ void main() { expect(component.layer, newLayer); }); + + flameTester.test('endContact changes layer', (game) async { + const oldLayer = Layer.all; + const newLayer = Layer.board; + final behavior = LayerContactBehavior( + layer: newLayer, + onBegin: false, + ); + final parent = _TestBodyComponent(); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + + final component = _TestLayeredBodyComponent(layer: oldLayer); + + behavior.endContact(component, _MockContact()); + + expect(component.layer, newLayer); + }); }); } diff --git a/packages/pinball_flame/test/src/behaviors/z_index_contact_behavior_test.dart b/packages/pinball_flame/test/src/behaviors/z_index_contact_behavior_test.dart index ad09004c..292a51fc 100644 --- a/packages/pinball_flame/test/src/behaviors/z_index_contact_behavior_test.dart +++ b/packages/pinball_flame/test/src/behaviors/z_index_contact_behavior_test.dart @@ -56,5 +56,20 @@ void main() { expect(component.zIndex, newIndex); }); + + flameTester.test('endContact changes zIndex', (game) async { + const oldIndex = 0; + const newIndex = 1; + final behavior = ZIndexContactBehavior(zIndex: newIndex, onBegin: false); + final parent = _TestBodyComponent(); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + + final component = _TestZIndexBodyComponent(zIndex: oldIndex); + + behavior.endContact(component, _MockContact()); + + expect(component.zIndex, newIndex); + }); }); } diff --git a/test/game/behaviors/bonus_ball_spawning_behavior_test.dart b/test/game/behaviors/bonus_ball_spawning_behavior_test.dart new file mode 100644 index 00000000..1aacf506 --- /dev/null +++ b/test/game/behaviors/bonus_ball_spawning_behavior_test.dart @@ -0,0 +1,61 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/forge2d_game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; + +class _TestGame extends Forge2DGame { + @override + Future onLoad() async { + images.prefix = ''; + await images.loadAll([ + theme.Assets.images.dash.ball.keyName, + ]); + } + + Future pump(BonusBallSpawningBehavior child) async { + await ensureAdd( + FlameBlocProvider.value( + value: CharacterThemeCubit(), + children: [ + ZCanvasComponent( + children: [child], + ), + ], + ), + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FlutterForestBonusBehavior', () { + final flameTester = FlameTester(_TestGame.new); + + flameTester.test( + 'adds a ball with a BallImpulsingBehavior to the game onTick ' + 'resulting in a -40 x impulse', + (game) async { + await game.onLoad(); + final behavior = BonusBallSpawningBehavior(); + + await game.pump(behavior); + + game.update(behavior.timer.limit); + await game.ready(); + + final ball = game.descendants().whereType().single; + + expect(ball.body.linearVelocity.x, equals(-40)); + expect(ball.body.linearVelocity.y, equals(0)); + }, + ); + }); +} diff --git a/test/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior_test.dart b/test/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior_test.dart index 7e8c2d2f..7fc1946b 100644 --- a/test/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior_test.dart +++ b/test/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior_test.dart @@ -5,9 +5,9 @@ import 'package:flame_forge2d/forge2d_game.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_theme/pinball_theme.dart' as theme; @@ -27,13 +27,8 @@ class _TestGame extends Forge2DGame { required GameBloc gameBloc, }) async { await ensureAdd( - FlameMultiBlocProvider( - providers: [ - FlameBlocProvider.value(value: gameBloc), - FlameBlocProvider.value( - value: CharacterThemeCubit(), - ), - ], + FlameBlocProvider.value( + value: gameBloc, children: [ ZCanvasComponent( children: [child], @@ -93,7 +88,7 @@ void main() { ); flameTester.testGameWidget( - 'adds a new Ball to the game ' + 'adds BonusBallSpawningBehavior to the game ' 'when bumpers are activated three times', setUp: (game, tester) async { await game.onLoad(); @@ -120,7 +115,7 @@ void main() { await game.ready(); expect( - game.descendants().whereType().length, + game.descendants().whereType().length, equals(1), ); },