From 655007b2d28c7219cb495d1cc7e08c18f41dd11d Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Wed, 6 Apr 2022 19:06:14 +0100 Subject: [PATCH] feat: improved extra ball logic (#151) --- lib/flame/component_controller.dart | 2 +- lib/game/bloc/game_bloc.dart | 14 +- lib/game/bloc/game_state.dart | 5 +- lib/game/components/controlled_ball.dart | 60 ++----- lib/game/components/wall.dart | 10 +- lib/game/pinball_game.dart | 106 ++++++++++--- test/game/bloc/game_bloc_test.dart | 23 +-- .../game/components/controlled_ball_test.dart | 146 +----------------- test/game/components/flutter_forest_test.dart | 16 +- test/game/components/wall_test.dart | 90 +++++++---- test/game/pinball_game_test.dart | 143 ++++++++++++++++- test/helpers/extensions.dart | 2 + test/helpers/forge2d.dart | 13 ++ test/helpers/helpers.dart | 1 + test/helpers/mocks.dart | 2 + 15 files changed, 339 insertions(+), 294 deletions(-) create mode 100644 test/helpers/forge2d.dart diff --git a/lib/flame/component_controller.dart b/lib/flame/component_controller.dart index 1d6e0173..b9568348 100644 --- a/lib/flame/component_controller.dart +++ b/lib/flame/component_controller.dart @@ -33,7 +33,7 @@ abstract class ComponentController extends Component { /// Mixin that attaches a single [ComponentController] to a [Component]. mixin Controls on Component { /// The [ComponentController] attached to this [Component]. - late final T controller; + late T controller; @override @mustCallSuper diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index c02417a7..7c1b4f44 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -19,9 +19,7 @@ class GameBloc extends Bloc { static const bonusWordScore = 10000; void _onBallLost(BallLost event, Emitter emit) { - if (state.balls > 0) { - emit(state.copyWith(balls: state.balls - 1)); - } + emit(state.copyWith(balls: state.balls - 1)); } void _onScored(Scored event, Emitter emit) { @@ -36,7 +34,8 @@ class GameBloc extends Bloc { event.letterIndex, ]; - if (newBonusLetters.length == bonusWord.length) { + final achievedBonus = newBonusLetters.length == bonusWord.length; + if (achievedBonus) { emit( state.copyWith( activatedBonusLetters: [], @@ -55,15 +54,16 @@ class GameBloc extends Bloc { } void _onDashNestActivated(DashNestActivated event, Emitter emit) { - const nestsRequiredForBonus = 3; - final newNests = { ...state.activatedDashNests, event.nestId, }; - if (newNests.length == nestsRequiredForBonus) { + + final achievedBonus = newNests.length == 3; + if (achievedBonus) { emit( state.copyWith( + balls: state.balls + 1, activatedDashNests: {}, bonusHistory: [ ...state.bonusHistory, diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index d08ba04b..bbaa4cd8 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -5,11 +5,10 @@ part of 'game_bloc.dart'; /// Defines bonuses that a player can gain during a PinballGame. enum GameBonus { /// Bonus achieved when the user activate all of the bonus - /// letters on the board, forming the bonus word + /// letters on the board, forming the bonus word. word, - /// Bonus achieved when the user activates all of the Dash - /// nests on the board, adding a new ball to the board. + /// Bonus achieved when the user activates all dash nest bumpers. dashNest, } diff --git a/lib/game/components/controlled_ball.dart b/lib/game/components/controlled_ball.dart index 1981e39c..cef076d8 100644 --- a/lib/game/components/controlled_ball.dart +++ b/lib/game/components/controlled_ball.dart @@ -1,5 +1,4 @@ import 'package:flame/components.dart'; -import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/forge2d_game.dart'; import 'package:flutter/material.dart'; import 'package:pinball/flame/flame.dart'; @@ -18,7 +17,7 @@ class ControlledBall extends Ball with Controls { ControlledBall.launch({ required PinballTheme theme, }) : super(baseColor: theme.characterTheme.ballColor) { - controller = LaunchedBallController(this); + controller = BallController(this); } /// {@template bonus_ball} @@ -29,74 +28,43 @@ class ControlledBall extends Ball with Controls { ControlledBall.bonus({ required PinballTheme theme, }) : super(baseColor: theme.characterTheme.ballColor) { - controller = BonusBallController(this); + controller = BallController(this); } /// [Ball] used in [DebugPinballGame]. ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) { - controller = BonusBallController(this); + controller = DebugBallController(this); } } /// {@template ball_controller} /// Controller attached to a [Ball] that handles its game related logic. /// {@endtemplate} -abstract class BallController extends ComponentController { +class BallController extends ComponentController + with HasGameRef { /// {@macro ball_controller} BallController(Ball ball) : super(ball); /// Removes the [Ball] from a [PinballGame]. /// - /// {@template ball_controller_lost} /// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into /// a [BottomWall]. - /// {@endtemplate} - void lost(); -} - -/// {@template bonus_ball_controller} -/// {@macro ball_controller} -/// -/// A [BonusBallController] doesn't change the [GameState.balls] count. -/// {@endtemplate} -class BonusBallController extends BallController { - /// {@macro bonus_ball_controller} - BonusBallController(Ball component) : super(component); - - @override void lost() { component.shouldRemove = true; } -} - -/// {@template launched_ball_controller} -/// {@macro ball_controller} -/// -/// A [LaunchedBallController] changes the [GameState.balls] count. -/// {@endtemplate} -class LaunchedBallController extends BallController - with HasGameRef, BlocComponent { - /// {@macro launched_ball_controller} - LaunchedBallController(Ball ball) : super(ball); @override - bool listenWhen(GameState? previousState, GameState newState) { - return (previousState?.balls ?? 0) > newState.balls; + void onRemove() { + super.onRemove(); + gameRef.read().add(const BallLost()); } +} - @override - void onNewState(GameState state) { - super.onNewState(state); - component.shouldRemove = true; - if (state.balls > 0) gameRef.spawnBall(); - } +/// {@macro ball_controller} +class DebugBallController extends BallController { + /// {@macro ball_controller} + DebugBallController(Ball component) : super(component); - /// Removes the [Ball] from a [PinballGame]; spawning a new [Ball] if - /// any are left. - /// - /// {@macro ball_controller_lost} @override - void lost() { - gameRef.read().add(const BallLost()); - } + void onRemove() {} } diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart index 030edc50..ba8af5e7 100644 --- a/lib/game/components/wall.dart +++ b/lib/game/components/wall.dart @@ -71,12 +71,12 @@ class BottomWall extends Wall { } /// {@template bottom_wall_ball_contact_callback} -/// Listens when a [Ball] falls into a [BottomWall]. +/// Listens when a [ControlledBall] falls into a [BottomWall]. /// {@endtemplate} -class BottomWallBallContactCallback extends ContactCallback { +class BottomWallBallContactCallback + extends ContactCallback { @override - void begin(Ball ball, BottomWall wall, Contact contact) { - // TODO(alestiago): replace with .firstChild when available. - ball.children.whereType().first.lost(); + void begin(ControlledBall ball, BottomWall wall, Contact contact) { + ball.controller.lost(); } } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index a6eb0884..7a0e6823 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -5,6 +5,7 @@ import 'package:flame/components.dart'; import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/flame/flame.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/gen/assets.gen.dart'; import 'package:pinball_audio/pinball_audio.dart'; @@ -12,29 +13,38 @@ import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart' hide Assets; class PinballGame extends Forge2DGame - with FlameBloc, HasKeyboardHandlerComponents { - PinballGame({required this.theme, required this.audio}) { + with + FlameBloc, + HasKeyboardHandlerComponents, + Controls<_GameBallsController> { + PinballGame({ + required this.theme, + required this.audio, + }) { images.prefix = ''; + controller = _GameBallsController(this); } final PinballTheme theme; final PinballAudio audio; - @override - void onAttach() { - super.onAttach(); - spawnBall(); - } - @override Future onLoad() async { _addContactCallbacks(); + // Fix camera on the center of the board. + camera + ..followVector2(Vector2(0, -7.8)) + ..zoom = size.y / 16; await _addGameBoundaries(); unawaited(addFromBlueprint(Boundaries())); unawaited(addFromBlueprint(LaunchRamp())); - unawaited(_addPlunger()); + + final plunger = Plunger(compressionDistance: 29) + ..initialPosition = Vector2(38, -19); + await add(plunger); + unawaited(add(Board())); unawaited(addFromBlueprint(DinoWalls())); unawaited(_addBonusWord()); @@ -52,10 +62,8 @@ class PinballGame extends Forge2DGame ), ); - // Fix camera on the center of the board. - camera - ..followVector2(Vector2(0, -7.8)) - ..zoom = size.y / 16; + controller.attachTo(plunger); + await super.onLoad(); } void _addContactCallbacks() { @@ -69,12 +77,6 @@ class PinballGame extends Forge2DGame createBoundaries(this).forEach(add); } - Future _addPlunger() async { - final plunger = Plunger(compressionDistance: 29) - ..initialPosition = Vector2(38, -19); - await add(plunger); - } - Future _addBonusWord() async { await add( BonusWord( @@ -85,13 +87,49 @@ class PinballGame extends Forge2DGame ), ); } +} - Future spawnBall() async { - // TODO(alestiago): Remove once this logic is moved to controller. +class _GameBallsController extends ComponentController + with BlocComponent, HasGameRef { + _GameBallsController(PinballGame game) : super(game); + + late final Plunger _plunger; + + @override + bool listenWhen(GameState? previousState, GameState newState) { + final noBallsLeft = component.descendants().whereType().isEmpty; + final canBallRespawn = newState.balls > 0; + + return noBallsLeft && canBallRespawn; + } + + @override + void onNewState(GameState state) { + super.onNewState(state); + _spawnBall(); + } + + @override + Future onLoad() async { + await super.onLoad(); + _spawnBall(); + } + + void _spawnBall() { final ball = ControlledBall.launch( - theme: theme, - )..initialPosition = Vector2(38, -19 + Ball.size.y); - await add(ball); + theme: gameRef.theme, + )..initialPosition = Vector2( + _plunger.body.position.x, + _plunger.body.position.y + Ball.size.y, + ); + component.add(ball); + } + + /// Attaches the controller to the plunger. + // TODO(alestiago): Remove this method and use onLoad instead. + // ignore: use_setters_to_change_properties + void attachTo(Plunger plunger) { + _plunger = plunger; } } @@ -102,7 +140,9 @@ class DebugPinballGame extends PinballGame with TapDetector { }) : super( theme: theme, audio: audio, - ); + ) { + controller = _DebugGameBallsController(this); + } @override Future onLoad() async { @@ -134,3 +174,19 @@ class DebugPinballGame extends PinballGame with TapDetector { ); } } + +class _DebugGameBallsController extends _GameBallsController { + _DebugGameBallsController(PinballGame game) : super(game); + + @override + bool listenWhen(GameState? previousState, GameState newState) { + final noBallsLeft = component + .descendants() + .whereType() + .where((ball) => ball.controller is! DebugBallController) + .isEmpty; + final canBallRespawn = newState.balls > 0; + + return noBallsLeft && canBallRespawn; + } +} diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart index f4b79001..8ec53106 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -12,13 +12,10 @@ void main() { group('LostBall', () { blocTest( - "doesn't decrease ball " - 'when no balls left', + 'decreases number of balls', build: GameBloc.new, act: (bloc) { - for (var i = 0; i <= bloc.state.balls; i++) { - bloc.add(const BallLost()); - } + bloc.add(const BallLost()); }, expect: () => [ const GameState( @@ -28,20 +25,6 @@ void main() { activatedDashNests: {}, bonusHistory: [], ), - const GameState( - score: 0, - balls: 1, - activatedBonusLetters: [], - activatedDashNests: {}, - bonusHistory: [], - ), - const GameState( - score: 0, - balls: 0, - activatedBonusLetters: [], - activatedDashNests: {}, - bonusHistory: [], - ), ], ); }); @@ -230,7 +213,7 @@ void main() { ), GameState( score: 0, - balls: 3, + balls: 4, activatedBonusLetters: [], activatedDashNests: {}, bonusHistory: [GameBonus.dashNest], diff --git a/test/game/components/controlled_ball_test.dart b/test/game/components/controlled_ball_test.dart index 05056484..53847b3c 100644 --- a/test/game/components/controlled_ball_test.dart +++ b/test/game/components/controlled_ball_test.dart @@ -13,42 +13,12 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(EmptyPinballGameTest.new); - group('BonusBallController', () { - late Ball ball; - - setUp(() { - ball = Ball(baseColor: const Color(0xFF00FFFF)); - }); - - test('can be instantiated', () { - expect( - BonusBallController(ball), - isA(), - ); - }); - - flameTester.test( - 'lost removes ball', - (game) async { - await game.add(ball); - final controller = BonusBallController(ball); - await ball.ensureAdd(controller); - - controller.lost(); - await game.ready(); - - expect(game.contains(ball), isFalse); - }, - ); - }); - - group('LaunchedBallController', () { + group('BallController', () { test('can be instantiated', () { expect( - LaunchedBallController(MockBall()), - isA(), + BallController(MockBall()), + isA(), ); }); @@ -74,7 +44,7 @@ void main() { flameBlocTester.testGameWidget( 'lost adds BallLost to GameBloc', setUp: (game, tester) async { - final controller = LaunchedBallController(ball); + final controller = BallController(ball); await ball.add(controller); await game.ensureAdd(ball); @@ -84,114 +54,6 @@ void main() { verify(() => gameBloc.add(const BallLost())).called(1); }, ); - - group('listenWhen', () { - flameBlocTester.testGameWidget( - 'listens when a ball has been lost', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - - await ball.add(controller); - await game.ensureAdd(ball); - }, - verify: (game, tester) async { - final controller = - game.descendants().whereType().first; - - final previousState = MockGameState(); - final newState = MockGameState(); - when(() => previousState.balls).thenReturn(3); - when(() => newState.balls).thenReturn(2); - - expect(controller.listenWhen(previousState, newState), isTrue); - }, - ); - - flameBlocTester.testGameWidget( - 'does not listen when a ball has not been lost', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - - await ball.add(controller); - await game.ensureAdd(ball); - }, - verify: (game, tester) async { - final controller = - game.descendants().whereType().first; - - final previousState = MockGameState(); - final newState = MockGameState(); - when(() => previousState.balls).thenReturn(3); - when(() => newState.balls).thenReturn(3); - - expect(controller.listenWhen(previousState, newState), isFalse); - }, - ); - }); - - group('onNewState', () { - flameBlocTester.testGameWidget( - 'removes ball', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - await ball.add(controller); - await game.ensureAdd(ball); - - final state = MockGameState(); - when(() => state.balls).thenReturn(1); - controller.onNewState(state); - await game.ready(); - }, - verify: (game, tester) async { - expect(game.contains(ball), isFalse); - }, - ); - - flameBlocTester.testGameWidget( - 'spawns a new ball when the ball is not the last one', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - await ball.add(controller); - await game.ensureAdd(ball); - - final state = MockGameState(); - when(() => state.balls).thenReturn(1); - - final previousBalls = game.descendants().whereType().toList(); - controller.onNewState(state); - await game.ready(); - - final currentBalls = game.descendants().whereType().toList(); - - expect(currentBalls.contains(ball), isFalse); - expect(currentBalls.length, equals(previousBalls.length)); - }, - ); - - flameBlocTester.testGameWidget( - 'does not spawn a new ball is the last one', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - await ball.add(controller); - await game.ensureAdd(ball); - - final state = MockGameState(); - when(() => state.balls).thenReturn(0); - - final previousBalls = game.descendants().whereType().toList(); - controller.onNewState(state); - await game.ready(); - - final currentBalls = game.descendants().whereType(); - - expect(currentBalls.contains(ball), isFalse); - expect( - currentBalls.length, - equals((previousBalls..remove(ball)).length), - ); - }, - ); - }); }); }); } diff --git a/test/game/components/flutter_forest_test.dart b/test/game/components/flutter_forest_test.dart index 60c55be9..d85fe54f 100644 --- a/test/game/components/flutter_forest_test.dart +++ b/test/game/components/flutter_forest_test.dart @@ -1,7 +1,6 @@ // ignore_for_file: cascade_invocations import 'package:bloc_test/bloc_test.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -11,18 +10,6 @@ import 'package:pinball_components/pinball_components.dart'; import '../../helpers/helpers.dart'; -void beginContact(Forge2DGame game, BodyComponent bodyA, BodyComponent bodyB) { - assert( - bodyA.body.fixtures.isNotEmpty && bodyB.body.fixtures.isNotEmpty, - 'Bodies require fixtures to contact each other.', - ); - - final fixtureA = bodyA.body.fixtures.first; - final fixtureB = bodyB.body.fixtures.first; - final contact = Contact.init(fixtureA, 0, fixtureB, 0); - game.world.contactManager.contactListener?.beginContact(contact); -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); final flameTester = FlameTester(EmptyPinballGameTest.new); @@ -92,7 +79,7 @@ void main() { ); flameBlocTester.testGameWidget( - 'listens when a Bonus.dashNest is added', + 'listens when a Bonus.dashNest and a bonusBall is added', verify: (game, tester) async { final flutterForest = FlutterForest(); @@ -103,6 +90,7 @@ void main() { activatedDashNests: {}, bonusHistory: [GameBonus.dashNest], ); + expect( flutterForest.controller .listenWhen(const GameState.initial(), state), diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart index 18c7ea5b..f8e7483c 100644 --- a/test/game/components/wall_test.dart +++ b/test/game/components/wall_test.dart @@ -3,40 +3,15 @@ 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'; import 'package:pinball/game/game.dart'; import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(Forge2DGame.new); + final flameTester = FlameTester(EmptyPinballGameTest.new); group('Wall', () { - group('BottomWallBallContactCallback', () { - test( - 'removes the ball on begin contact when the wall is a bottom one', - () { - final wall = MockBottomWall(); - final ballController = MockBallController(); - final ball = MockBall(); - final componentSet = MockComponentSet(); - - when(() => componentSet.whereType()) - .thenReturn([ballController]); - when(() => ball.children).thenReturn(componentSet); - - BottomWallBallContactCallback() - // Remove once https://github.com/flame-engine/flame/pull/1415 - // is merged - ..end(MockBall(), MockBottomWall(), MockContact()) - ..begin(ball, wall, MockContact()); - - verify(ballController.lost).called(1); - }, - ); - }); - flameTester.test( 'loads correctly', (game) async { @@ -123,4 +98,67 @@ void main() { ); }); }); + + group( + 'BottomWall', + () { + group('removes ball on contact', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = GameBloc(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballGameTest.new, + blocBuilder: () => gameBloc, + ); + + flameBlocTester.testGameWidget( + 'when ball is launch', + setUp: (game, tester) async { + final ball = ControlledBall.launch(theme: game.theme); + final wall = BottomWall(); + await game.ensureAddAll([ball, wall]); + game.addContactCallback(BottomWallBallContactCallback()); + + beginContact(game, ball, wall); + await game.ready(); + + expect(game.contains(ball), isFalse); + }, + ); + + flameBlocTester.testGameWidget( + 'when ball is bonus', + setUp: (game, tester) async { + final ball = ControlledBall.bonus(theme: game.theme); + final wall = BottomWall(); + await game.ensureAddAll([ball, wall]); + game.addContactCallback(BottomWallBallContactCallback()); + + beginContact(game, ball, wall); + await game.ready(); + + expect(game.contains(ball), isFalse); + }, + ); + + flameTester.test( + 'when ball is debug', + (game) async { + final ball = ControlledBall.debug(); + final wall = BottomWall(); + await game.ensureAddAll([ball, wall]); + game.addContactCallback(BottomWallBallContactCallback()); + + beginContact(game, ball, wall); + await game.ready(); + + expect(game.contains(ball), isFalse); + }, + ); + }); + }, + ); } diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index f418bad0..d83bb396 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: cascade_invocations import 'package:flame/components.dart'; +import 'package:flame/game.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -10,11 +11,11 @@ import 'package:pinball_components/pinball_components.dart'; import '../helpers/helpers.dart'; void main() { - group('PinballGame', () { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.new); - final debugModeFlameTester = FlameTester(DebugPinballGameTest.new); + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.new); + final debugModeFlameTester = FlameTester(DebugPinballGameTest.new); + group('PinballGame', () { // TODO(alestiago): test if [PinballGame] registers // [BallScorePointsCallback] once the following issue is resolved: // https://github.com/flame-engine/flame/issues/1416 @@ -60,8 +61,106 @@ void main() { equals(1), ); }); + + group('controller', () { + // TODO(alestiago): Write test to be controller agnostic. + group('listenWhen', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = GameBloc(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballGameTest.new, + blocBuilder: () => gameBloc, + ); + + flameBlocTester.testGameWidget( + 'listens when all balls are lost and there are more than 0 balls', + setUp: (game, tester) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(2); + game.descendants().whereType().forEach( + (ball) => ball.controller.lost(), + ); + await game.ready(); + + expect( + game.controller.listenWhen(MockGameState(), newState), + isTrue, + ); + }, + ); + + flameTester.test( + "doesn't listen when some balls are left", + (game) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(1); + + expect( + game.descendants().whereType().length, + greaterThan(0), + ); + expect( + game.controller.listenWhen(MockGameState(), newState), + isFalse, + ); + }, + ); + + flameBlocTester.test( + "doesn't listen when no balls left", + (game) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(0); + + game.descendants().whereType().forEach( + (ball) => ball.controller.lost(), + ); + await game.ready(); + + expect( + game.descendants().whereType().isEmpty, + isTrue, + ); + expect( + game.controller.listenWhen(MockGameState(), newState), + isFalse, + ); + }, + ); + }); + + group( + 'onNewState', + () { + flameTester.test( + 'spawns a ball', + (game) async { + await game.ready(); + final previousBalls = + game.descendants().whereType().toList(); + + game.controller.onNewState(MockGameState()); + await game.ready(); + final currentBalls = + game.descendants().whereType().toList(); + + expect( + currentBalls.length, + equals(previousBalls.length + 1), + ); + }, + ); + }, + ); + }); }); + }); + group('DebugPinballGame', () { debugModeFlameTester.test('adds a ball on tap up', (game) async { await game.ready(); @@ -71,12 +170,46 @@ void main() { final tapUpEvent = MockTapUpInfo(); when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); + final previousBalls = game.descendants().whereType().toList(); + game.onTapUp(tapUpEvent); await game.ready(); expect( game.children.whereType().length, - equals(1), + equals(previousBalls.length + 1), + ); + }); + + group('controller', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = GameBloc(); + }); + + final debugModeFlameBlocTester = + FlameBlocTester( + gameBuilder: DebugPinballGameTest.new, + blocBuilder: () => gameBloc, + ); + + debugModeFlameBlocTester.testGameWidget( + 'ignores debug balls', + setUp: (game, tester) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(1); + + await game.ready(); + game.children.removeWhere((component) => component is Ball); + await game.ready(); + await game.ensureAdd(ControlledBall.debug()); + + expect( + game.controller.listenWhen(MockGameState(), newState), + isTrue, + ); + }, ); }); }); diff --git a/test/helpers/extensions.dart b/test/helpers/extensions.dart index 4731eec4..8e054fe0 100644 --- a/test/helpers/extensions.dart +++ b/test/helpers/extensions.dart @@ -1,3 +1,5 @@ +// ignore_for_file: must_call_super + import 'package:pinball/game/game.dart'; import 'package:pinball_theme/pinball_theme.dart'; diff --git a/test/helpers/forge2d.dart b/test/helpers/forge2d.dart new file mode 100644 index 00000000..f000d404 --- /dev/null +++ b/test/helpers/forge2d.dart @@ -0,0 +1,13 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; + +void beginContact(Forge2DGame game, BodyComponent bodyA, BodyComponent bodyB) { + assert( + bodyA.body.fixtures.isNotEmpty && bodyB.body.fixtures.isNotEmpty, + 'Bodies require fixtures to contact each other.', + ); + + final fixtureA = bodyA.body.fixtures.first; + final fixtureB = bodyB.body.fixtures.first; + final contact = Contact.init(fixtureA, 0, fixtureB, 0); + game.world.contactManager.contactListener?.beginContact(contact); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index d9dc2a17..4b6c29f1 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -7,6 +7,7 @@ export 'builders.dart'; export 'extensions.dart'; export 'fakes.dart'; +export 'forge2d.dart'; export 'key_testers.dart'; export 'mocks.dart'; export 'navigator.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index c0dec5f5..748b48f3 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -21,6 +21,8 @@ class MockBody extends Mock implements Body {} class MockBall extends Mock implements Ball {} +class MockControlledBall extends Mock implements ControlledBall {} + class MockBallController extends Mock implements BallController {} class MockContact extends Mock implements Contact {}