diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 235e264d..1a0568f7 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -26,6 +26,9 @@ class GameState extends Equatable { /// Determines when the game is over. bool get isGameOver => balls == 0; + /// Determines when the player has only one ball left. + bool get isLastBall => balls == 1; + GameState copyWith({ int? score, int? balls, diff --git a/lib/game/components/anchor.dart b/lib/game/components/anchor.dart new file mode 100644 index 00000000..0e78aa1c --- /dev/null +++ b/lib/game/components/anchor.dart @@ -0,0 +1,32 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; + +/// {@template anchor} +/// Non visual [BodyComponent] used to hold a [BodyType.dynamic] in [Joint]s +/// with this [BodyType.static]. +/// +/// It is recommended to [_position] the anchor first and then use the body +/// position as the anchor point when initializing a [JointDef]. +/// +/// ```dart +/// initialize( +/// dynamicBody.body, +/// anchor.body, +/// anchor.body.position, +/// ); +/// ``` +/// {@endtemplate} +class Anchor extends BodyComponent { + /// {@macro anchor} + Anchor({ + required Vector2 position, + }) : _position = position; + + final Vector2 _position; + + @override + Body createBody() { + final bodyDef = BodyDef()..position = _position; + + return world.createBody(bodyDef); + } +} diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index 8ff24094..e285b14b 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -28,4 +28,15 @@ class Ball extends BodyComponent return world.createBody(bodyDef)..createFixture(fixtureDef); } + + void lost() { + shouldRemove = true; + + final bloc = gameRef.read()..add(const BallLost()); + + final shouldBallRespwan = !bloc.state.isLastBall; + if (shouldBallRespwan) { + gameRef.spawnBall(); + } + } } diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 28541aed..95134ec2 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,3 +1,5 @@ +export 'anchor.dart'; export 'ball.dart'; export 'plunger.dart'; export 'score_points.dart'; +export 'wall.dart'; diff --git a/lib/game/components/score_points.dart b/lib/game/components/score_points.dart index 02506c8c..c6474b16 100644 --- a/lib/game/components/score_points.dart +++ b/lib/game/components/score_points.dart @@ -17,10 +17,10 @@ class BallScorePointsCallback extends ContactCallback { @override void begin( Ball ball, - ScorePoints hasPoints, + ScorePoints scorePoints, Contact _, ) { - ball.gameRef.read().add(Scored(points: hasPoints.points)); + ball.gameRef.read().add(Scored(points: scorePoints.points)); } // TODO(alestiago): remove once this issue is closed. diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart new file mode 100644 index 00000000..7bf58273 --- /dev/null +++ b/lib/game/components/wall.dart @@ -0,0 +1,51 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/components/components.dart'; + +class Wall extends BodyComponent { + Wall({ + required this.start, + required this.end, + }); + + final Vector2 start; + final Vector2 end; + + @override + Body createBody() { + final shape = EdgeShape()..set(start, end); + + final fixtureDef = FixtureDef(shape) + ..restitution = 0.0 + ..friction = 0.3; + + final bodyDef = BodyDef() + ..userData = this + ..position = Vector2.zero() + ..type = BodyType.static; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} + +class BottomWall extends Wall { + BottomWall(Forge2DGame game) + : super( + start: game.screenToWorld(game.camera.viewport.effectiveSize), + end: Vector2( + 0, + game.screenToWorld(game.camera.viewport.effectiveSize).y, + ), + ); +} + +class BottomWallBallContactCallback extends ContactCallback { + @override + void begin(Ball ball, BottomWall wall, Contact contact) { + ball.lost(); + } + + @override + void end(_, __, ___) {} +} diff --git a/lib/game/game.dart b/lib/game/game.dart index e2e5361f..253dcc9f 100644 --- a/lib/game/game.dart +++ b/lib/game/game.dart @@ -1,4 +1,4 @@ export 'bloc/game_bloc.dart'; export 'components/components.dart'; export 'pinball_game.dart'; -export 'view/pinball_game_page.dart'; +export 'view/view.dart'; diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index f7b2777f..91e12854 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -3,8 +3,25 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/game.dart'; class PinballGame extends Forge2DGame with FlameBloc { + void spawnBall() { + add(Ball(position: ballStartingPosition)); + } + + // TODO(erickzanardo): Change to the plumber position + late final ballStartingPosition = screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2, + camera.viewport.effectiveSize.y - 20, + ), + ) - + Vector2(0, -20); + @override Future onLoad() async { + spawnBall(); addContactCallback(BallScorePointsCallback()); + + await add(BottomWall(this)); + addContactCallback(BottomWallBallContactCallback()); } } diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index cfe8d5bc..28834907 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -1,16 +1,45 @@ import 'package:flame/game.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; class PinballGamePage extends StatelessWidget { const PinballGamePage({Key? key}) : super(key: key); static Route route() { - return MaterialPageRoute(builder: (_) => const PinballGamePage()); + return MaterialPageRoute( + builder: (_) { + return BlocProvider( + create: (_) => GameBloc(), + child: const PinballGamePage(), + ); + }, + ); } @override Widget build(BuildContext context) { - return GameWidget(game: PinballGame()); + return const PinballGameView(); + } +} + +class PinballGameView extends StatelessWidget { + const PinballGameView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.isGameOver) { + showDialog( + context: context, + builder: (_) { + return const GameOverDialog(); + }, + ); + } + }, + child: GameWidget(game: PinballGame()), + ); } } diff --git a/lib/game/view/view.dart b/lib/game/view/view.dart new file mode 100644 index 00000000..53d3813a --- /dev/null +++ b/lib/game/view/view.dart @@ -0,0 +1,2 @@ +export 'pinball_game_page.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/game/view/widgets/game_over_dialog.dart b/lib/game/view/widgets/game_over_dialog.dart new file mode 100644 index 00000000..586d6c56 --- /dev/null +++ b/lib/game/view/widgets/game_over_dialog.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class GameOverDialog extends StatelessWidget { + const GameOverDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Dialog( + child: SizedBox( + width: 200, + height: 200, + child: Center( + child: Text('Game Over'), + ), + ), + ); + } +} diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart new file mode 100644 index 00000000..9c457b1c --- /dev/null +++ b/lib/game/view/widgets/widgets.dart @@ -0,0 +1 @@ +export 'game_over_dialog.dart'; diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index f62bae67..59cc0d1d 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -62,6 +62,32 @@ void main() { }); }); + group('isLastBall', () { + test( + 'is true ' + 'when there is only one ball left', + () { + const gameState = GameState( + balls: 1, + score: 0, + ); + expect(gameState.isLastBall, isTrue); + }, + ); + + test( + 'is false ' + 'when there are more balls left', + () { + const gameState = GameState( + balls: 2, + score: 0, + ); + expect(gameState.isLastBall, isFalse); + }, + ); + }); + group('copyWith', () { test( 'throws AssertionError ' diff --git a/test/game/components/anchor_test.dart b/test/game/components/anchor_test.dart new file mode 100644 index 00000000..5cc37eca --- /dev/null +++ b/test/game/components/anchor_test.dart @@ -0,0 +1,60 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Anchor', () { + final flameTester = FlameTester(PinballGame.new); + + flameTester.test( + 'loads correctly', + (game) async { + final anchor = Anchor(position: Vector2.zero()); + await game.ensureAdd(anchor); + + expect(game.contains(anchor), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final anchor = Anchor(position: position); + await game.ensureAdd(anchor); + game.contains(anchor); + + expect(anchor.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final anchor = Anchor(position: Vector2.zero()); + await game.ensureAdd(anchor); + + expect(anchor.body.bodyType, equals(BodyType.static)); + }, + ); + }); + + group('fixtures', () { + flameTester.test( + 'has none', + (game) async { + final anchor = Anchor(position: Vector2.zero()); + await game.ensureAdd(anchor); + + expect(anchor.body.fixtures, isEmpty); + }, + ); + }); + }); +} diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index c4576c68..b32d16d5 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -1,10 +1,14 @@ // 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_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import '../../helpers/helpers.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -79,5 +83,71 @@ void main() { }, ); }); + + group('resetting a ball', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final tester = flameBlocTester( + gameBlocBuilder: () { + return gameBloc; + }, + ); + + tester.widgetTest( + 'adds BallLost to GameBloc', + (game, tester) async { + await game.ready(); + + game.children.whereType().first.lost(); + await tester.pump(); + + verify(() => gameBloc.add(const BallLost())).called(1); + }, + ); + + tester.widgetTest( + 'resets the ball if the game is not over', + (game, tester) async { + await game.ready(); + + game.children.whereType().first.removeFromParent(); + await game.ready(); // Making sure that all additions are done + + expect( + game.children.whereType().length, + equals(1), + ); + }, + ); + + tester.widgetTest( + 'no ball is added on game over', + (game, tester) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState(score: 10, balls: 1), + ); + await game.ready(); + + game.children.whereType().first.removeFromParent(); + await tester.pump(); + + expect( + game.children.whereType().length, + equals(0), + ); + }, + ); + }); }); } diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart new file mode 100644 index 00000000..8151055e --- /dev/null +++ b/test/game/components/wall_test.dart @@ -0,0 +1,122 @@ +// ignore_for_file: cascade_invocations + +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(); + + group('Wall', () { + group('BottomWallBallContactCallback', () { + test( + 'removes the ball on begin contact when the wall is a bottom one', + () { + final game = MockPinballGame(); + final wall = MockBottomWall(); + final ball = MockBall(); + + when(() => ball.gameRef).thenReturn(game); + + BottomWallBallContactCallback() + // Remove once https://github.com/flame-engine/flame/pull/1415 + // is merged + ..end(MockBall(), MockBottomWall(), MockContact()) + ..begin(ball, wall, MockContact()); + + verify(ball.lost).called(1); + }, + ); + }); + final flameTester = FlameTester(PinballGame.new); + + flameTester.test( + 'loads correctly', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + expect(game.contains(wall), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + game.contains(wall); + + expect(wall.body.position, Vector2.zero()); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + expect(wall.body.bodyType, equals(BodyType.static)); + }, + ); + }); + + group('first fixture', () { + flameTester.test( + 'exists', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + expect(wall.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'has restitution equals 0', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + final fixture = wall.body.fixtures[0]; + expect(fixture.restitution, equals(0)); + }, + ); + + flameTester.test( + 'has friction', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + final fixture = wall.body.fixtures[0]; + expect(fixture.friction, greaterThan(0)); + }, + ); + }); + }); +} diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 955ce763..be418c1d 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -1,4 +1,6 @@ +import 'package:bloc_test/bloc_test.dart'; import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; @@ -6,9 +8,80 @@ import '../../helpers/helpers.dart'; void main() { group('PinballGamePage', () { - testWidgets('renders single GameWidget with PinballGame', (tester) async { - await tester.pumpApp(const PinballGamePage()); - expect(find.byType(GameWidget), findsOneWidget); + testWidgets('renders PinballGameView', (tester) async { + final gameBloc = MockGameBloc(); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); + + await tester.pumpApp(const PinballGamePage(), gameBloc: gameBloc); + expect(find.byType(PinballGameView), findsOneWidget); }); + + testWidgets('route returns a valid navigation route', (tester) async { + await tester.pumpApp( + Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context).push(PinballGamePage.route()); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Tap me')); + + // We can't use pumpAndSettle here because the page renders a Flame game + // which is an infinity animation, so it will timeout + await tester.pump(); // Runs the button action + await tester.pump(); // Runs the navigation + + expect(find.byType(PinballGamePage), findsOneWidget); + }); + }); + + group('PinballGameView', () { + testWidgets('renders game', (tester) async { + final gameBloc = MockGameBloc(); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); + + await tester.pumpApp(const PinballGameView(), gameBloc: gameBloc); + expect( + find.byWidgetPredicate((w) => w is GameWidget), + findsOneWidget, + ); + }); + + testWidgets( + 'renders a game over dialog when the user has lost', + (tester) async { + final gameBloc = MockGameBloc(); + const state = GameState(score: 0, balls: 0); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + + await tester.pumpApp(const PinballGameView(), gameBloc: gameBloc); + await tester.pump(); + + expect( + find.text('Game Over'), + findsOneWidget, + ); + }, + ); }); } diff --git a/test/helpers/builders.dart b/test/helpers/builders.dart new file mode 100644 index 00000000..5ef98226 --- /dev/null +++ b/test/helpers/builders.dart @@ -0,0 +1,17 @@ +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; + +FlameTester flameBlocTester({required GameBloc Function() gameBlocBuilder}) { + return FlameTester( + PinballGame.new, + pumpWidget: (gameWidget, tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: gameBlocBuilder(), + child: gameWidget, + ), + ); + }, + ); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 695f8309..97bc22be 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -5,4 +5,6 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +export 'builders.dart'; +export 'mocks.dart'; export 'pump_app.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart new file mode 100644 index 00000000..b46e2c5c --- /dev/null +++ b/test/helpers/mocks.dart @@ -0,0 +1,15 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; + +class MockPinballGame extends Mock implements PinballGame {} + +class MockWall extends Mock implements Wall {} + +class MockBottomWall extends Mock implements BottomWall {} + +class MockBall extends Mock implements Ball {} + +class MockContact extends Mock implements Contact {} + +class MockGameBloc extends Mock implements GameBloc {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index 97ca4590..2c1efd9f 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -6,15 +6,20 @@ // https://opensource.org/licenses/MIT. import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; +import 'helpers.dart'; + extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { MockNavigator? navigator, + GameBloc? gameBloc, }) { return pumpWidget( MaterialApp( @@ -23,9 +28,12 @@ extension PumpApp on WidgetTester { GlobalMaterialLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: navigator != null - ? MockNavigatorProvider(navigator: navigator, child: widget) - : widget, + home: BlocProvider.value( + value: gameBloc ?? MockGameBloc(), + child: navigator != null + ? MockNavigatorProvider(navigator: navigator, child: widget) + : widget, + ), ), ); }