From 518e183ad89920a55bd47802381ba22b7b2704bb Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Wed, 2 Mar 2022 18:18:25 -0300 Subject: [PATCH] feat: game over mechanics --- lib/game/components/ball.dart | 16 ++++ lib/game/components/components.dart | 1 + lib/game/components/wall.dart | 24 +++++ lib/game/game.dart | 2 +- lib/game/pinball_game.dart | 39 ++++++++ lib/game/view/pinball_game_page.dart | 13 ++- lib/game/view/pinball_game_view.dart | 33 +++++++ lib/game/view/view.dart | 2 + test/game/components/wall_test.dart | 81 ++++++++++++++++ test/game/pinball_game_test.dart | 106 ++++++++++++++++++++- test/game/view/pinball_game_page_test.dart | 42 +++++++- test/game/view/pinball_game_view_test.dart | 46 +++++++++ test/helpers/pump_app.dart | 14 ++- 13 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 lib/game/components/wall.dart create mode 100644 lib/game/view/pinball_game_view.dart create mode 100644 lib/game/view/view.dart create mode 100644 test/game/components/wall_test.dart create mode 100644 test/game/view/pinball_game_view_test.dart diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index 8ff24094..3aa978d3 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -6,6 +6,7 @@ import 'package:pinball/game/game.dart'; class Ball extends BodyComponent with BlocComponent { + Ball({ required Vector2 position, }) : _position = position { @@ -28,4 +29,19 @@ class Ball extends BodyComponent return world.createBody(bodyDef)..createFixture(fixtureDef); } + + @override + void onRemove() { + final bloc = gameRef.read(); + + final shouldBallrespwan = bloc.state.balls > 1; + + bloc.add(const BallLost()); + + if (shouldBallrespwan) { + gameRef.resetBall(); + } + + super.onRemove(); + } } diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index e3d2c6ce..52e460a5 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,2 +1,3 @@ export 'ball.dart'; export 'score_points.dart'; +export 'wall.dart'; diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart new file mode 100644 index 00000000..de93e1ce --- /dev/null +++ b/lib/game/components/wall.dart @@ -0,0 +1,24 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; + +class Wall extends BodyComponent { + Wall(this.start, 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); + } +} 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 c4977435..0c160316 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -1,10 +1,49 @@ +// ignore_for_file: avoid_renaming_method_parameters + import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/forge2d_game.dart'; import 'package:pinball/game/game.dart'; +class BallWallContactCallback extends ContactCallback { + @override + void begin(Ball ball, Wall wall, Contact contact) { + ball.shouldRemove = true; + } + + @override + void end(_, __, ___) {} +} + class PinballGame extends Forge2DGame with FlameBloc { + void resetBall() { + add(Ball(position: ballStartingPosition)); + } + + late final ballStartingPosition = screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2, + camera.viewport.effectiveSize.y - 20, + ), + ) - + Vector2( + 0, + -20, + ); + @override Future onLoad() async { + await super.onLoad(); addContactCallback(BallScorePointsCallback()); + + final topLeft = Vector2.zero(); + final bottomRight = screenToWorld(camera.viewport.effectiveSize); + final bottomLeft = Vector2(topLeft.x, bottomRight.y); + + await add( + Wall(bottomRight, bottomLeft), + ); + addContactCallback(BallWallContactCallback()); + + resetBall(); } } diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index cfe8d5bc..c4387f34 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -1,16 +1,23 @@ -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(); } } diff --git a/lib/game/view/pinball_game_view.dart b/lib/game/view/pinball_game_view.dart new file mode 100644 index 00000000..5fbc8ae8 --- /dev/null +++ b/lib/game/view/pinball_game_view.dart @@ -0,0 +1,33 @@ +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; + +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 Dialog( + child: SizedBox( + width: 200, + height: 200, + child: Center( + child: Text('Game Over'), + ), + ), + ); + }, + ); + } + }, + child: GameWidget(game: PinballGame()), + ); + } +} diff --git a/lib/game/view/view.dart b/lib/game/view/view.dart new file mode 100644 index 00000000..66134941 --- /dev/null +++ b/lib/game/view/view.dart @@ -0,0 +1,2 @@ +export 'pinball_game_page.dart'; +export 'pinball_game_view.dart'; diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart new file mode 100644 index 00000000..e17537c0 --- /dev/null +++ b/test/game/components/wall_test.dart @@ -0,0 +1,81 @@ +// 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('Wall', () { + final flameTester = FlameTester(PinballGame.new); + + flameTester.test( + 'loads correctly', + (game) async { + final wall = Wall(Vector2.zero(), Vector2(100, 0)); + await game.ensureAdd(wall); + + expect(game.contains(wall), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final wall = Wall(Vector2.zero(), 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(Vector2.zero(), 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(Vector2.zero(), Vector2(100, 0)); + await game.ensureAdd(wall); + + expect(wall.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'has restitution equals 0', + (game) async { + final wall = Wall(Vector2.zero(), 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(Vector2.zero(), Vector2(100, 0)); + await game.ensureAdd(wall); + + final fixture = wall.body.fixtures[0]; + expect(fixture.friction, greaterThan(0)); + }, + ); + }); + }); +} diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 75a77aa9..a576252b 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -1,9 +1,109 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/game.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/game/game.dart'; + +class MockPinballGame extends Mock implements PinballGame {} + +class MockWall extends Mock implements Wall {} + +class MockBall extends Mock implements Ball {} + +class MockContact extends Mock implements Contact {} + +class MockGameBloc extends Mock implements GameBloc {} void main() { + // TODO(alestiago): test if [PinballGame] registers + // [BallScorePointsCallback] once the following issue is resolved: + // https://github.com/flame-engine/flame/issues/1416 group('PinballGame', () { - // TODO(alestiago): test if [PinballGame] registers - // [BallScorePointsCallback] once the following issue is resolved: - // https://github.com/flame-engine/flame/issues/1416 + group('BallWallContactCallback', () { + test('removes the ball on begin contact', () { + final game = MockPinballGame(); + final wall = MockWall(); + final ball = MockBall(); + + when(() => ball.gameRef).thenReturn(game); + + BallWallContactCallback() + // Remove once https://github.com/flame-engine/flame/pull/1415 + // is merged + ..end(MockBall(), MockWall(), MockContact()) + ..begin(ball, wall, MockContact()); + + verify(() => ball.shouldRemove = true).called(1); + }); + }); + + group('resetting a ball', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + FlameTester( + PinballGame.new, + pumpWidget: (gameWidget, tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: gameBloc, + child: gameWidget, + ), + ); + }, + ) + ..widgetTest('adds BallLost to GameBloc', (game, tester) async { + await game.ready(); + + game.children.whereType().first.removeFromParent(); + await tester.pump(); + + verify(() => gameBloc.add(const BallLost())).called(1); + }) + ..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), + ); + }, + ) + ..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/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 955ce763..2e005773 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -1,4 +1,5 @@ -import 'package:flame/game.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; @@ -6,9 +7,42 @@ 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); }); }); } diff --git a/test/game/view/pinball_game_view_test.dart b/test/game/view/pinball_game_view_test.dart new file mode 100644 index 00000000..c00db60f --- /dev/null +++ b/test/game/view/pinball_game_view_test.dart @@ -0,0 +1,46 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/game.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('PinballGameView', () { + testWidgets('renders', (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/pump_app.dart b/test/helpers/pump_app.dart index 97ca4590..ed5d4b1c 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'; +class MockGameBloc extends Mock implements GameBloc {} + 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, + ), ), ); }