diff --git a/.github/workflows/pinball_components.yaml b/.github/workflows/pinball_components.yaml index e4154059..d75553f5 100644 --- a/.github/workflows/pinball_components.yaml +++ b/.github/workflows/pinball_components.yaml @@ -13,7 +13,8 @@ on: jobs: build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@b075749771679a5baa4c90d36ad2e8580bbf273b + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 with: working_directory: packages/pinball_components coverage_excludes: "lib/gen/*.dart" + test_optimization: false diff --git a/lib/game/components/camera_controller.dart b/lib/game/components/camera_controller.dart new file mode 100644 index 00000000..aa963e9a --- /dev/null +++ b/lib/game/components/camera_controller.dart @@ -0,0 +1,80 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:pinball/flame/flame.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// Adds helpers methods to Flame's [Camera] +extension CameraX on Camera { + /// Instantly apply the point of focus to the [Camera] + void snapToFocus(FocusData data) { + followVector2(data.position); + zoom = data.zoom; + } + + /// Returns a [CameraZoom] that can be added to a [FlameGame] + CameraZoom focusToCameraZoom(FocusData data) { + final zoom = CameraZoom(value: data.zoom); + zoom.completed.then((_) { + moveTo(data.position); + }); + return zoom; + } +} + +/// {@template focus_data} +/// Model class that defines a focus point of the camera +/// {@endtemplate} +class FocusData { + /// {@template focus_data} + FocusData({ + required this.zoom, + required this.position, + }); + + /// The amount of zoom + final double zoom; + + /// The position of the camera + final Vector2 position; +} + +/// {@template camera_controller} +/// A [Component] that controls its game camera focus +/// {@endtemplate} +class CameraController extends ComponentController { + /// {@macro camera_controller} + CameraController(FlameGame component) : super(component) { + final gameZoom = component.size.y / 16; + final backboardZoom = component.size.y / 18; + + gameFocus = FocusData( + zoom: gameZoom, + position: Vector2(0, -7.8), + ); + backboardFocus = FocusData( + zoom: backboardZoom, + position: Vector2(0, -100.8), + ); + + // Game starts with the camera focused on the panel + component.camera + ..speed = 100 + ..snapToFocus(backboardFocus); + } + + /// Holds the data for the game focus point + late final FocusData gameFocus; + + /// Holds the data for the backboard focus point + late final FocusData backboardFocus; + + /// Move the camera focus to the game board + void focusOnGame() { + component.add(component.camera.focusToCameraZoom(gameFocus)); + } + + /// Move the camera focus to the backboard + void focusOnBackboard() { + component.add(component.camera.focusToCameraZoom(backboardFocus)); + } +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index a90e6bee..1e59ddfe 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,8 +1,10 @@ export 'board.dart'; export 'bonus_word.dart'; +export 'camera_controller.dart'; export 'controlled_ball.dart'; export 'controlled_flipper.dart'; export 'flutter_forest.dart'; +export 'game_flow_controller.dart'; export 'score_points.dart'; export 'sparky_fire_zone.dart'; export 'wall.dart'; diff --git a/lib/game/components/game_flow_controller.dart b/lib/game/components/game_flow_controller.dart new file mode 100644 index 00000000..b0f6f514 --- /dev/null +++ b/lib/game/components/game_flow_controller.dart @@ -0,0 +1,41 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/flame/flame.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template game_flow_controller} +/// A [Component] that controls the game over and game restart logic +/// {@endtemplate} +class GameFlowController extends ComponentController + with BlocComponent { + /// {@macro game_flow_controller} + GameFlowController(PinballGame component) : super(component); + + @override + bool listenWhen(GameState? previousState, GameState newState) { + return previousState?.isGameOver != newState.isGameOver; + } + + @override + void onNewState(GameState state) { + if (state.isGameOver) { + gameOver(); + } else { + start(); + } + } + + /// Puts the game on a game over state + void gameOver() { + component.firstChild()?.gameOverMode(); + component.firstChild()?.focusOnBackboard(); + } + + /// Puts the game on a playing state + void start() { + component.firstChild()?.waitingMode(); + component.firstChild()?.focusOnGame(); + component.overlays.remove(PinballGame.playButtonOverlay); + } +} diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index c39c0de4..851618c7 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -47,6 +47,8 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.chromeDino.mouth.keyName), images.load(components.Assets.images.chromeDino.head.keyName), images.load(components.Assets.images.plunger.plunger.keyName), + images.load(components.Assets.images.backboard.backboardScores.keyName), + images.load(components.Assets.images.backboard.backboardGameOver.keyName), images.load(Assets.images.components.background.path), ]; } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index a1054e83..ec0a665c 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -26,17 +26,22 @@ class PinballGame extends Forge2DGame controller = _GameBallsController(this); } + /// Identifier of the play button overlay + static const playButtonOverlay = 'play_button'; + final PinballTheme theme; final PinballAudio audio; + late final GameFlowController gameFlowController; + @override Future onLoad() async { _addContactCallbacks(); - // Fix camera on the center of the board. - camera - ..followVector2(Vector2(0, -7.8)) - ..zoom = size.y / 16; + + unawaited(add(gameFlowController = GameFlowController(this))); + unawaited(add(CameraController(this))); + unawaited(add(Backboard(position: Vector2(0, -88)))); await _addGameBoundaries(); unawaited(addFromBlueprint(Boundaries())); diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index b1f031c7..f6b7ee81 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -51,52 +51,18 @@ class PinballGamePage extends StatelessWidget { @override Widget build(BuildContext context) { - return PinballGameView(theme: theme, game: game); + return PinballGameView(game: game); } } class PinballGameView extends StatelessWidget { const PinballGameView({ Key? key, - required this.theme, required this.game, }) : super(key: key); - final PinballTheme theme; final PinballGame game; - @override - Widget build(BuildContext context) { - return BlocListener( - listenWhen: (previous, current) => - previous.isGameOver != current.isGameOver, - listener: (context, state) { - if (state.isGameOver) { - showDialog( - context: context, - builder: (_) { - return GameOverDialog( - score: state.score, - theme: theme.characterTheme, - ); - }, - ); - } - }, - child: _GameView(game: game), - ); - } -} - -class _GameView extends StatelessWidget { - const _GameView({ - Key? key, - required PinballGame game, - }) : _game = game, - super(key: key); - - final PinballGame _game; - @override Widget build(BuildContext context) { final loadingProgress = context.watch().state.progress; @@ -114,7 +80,20 @@ class _GameView extends StatelessWidget { return Stack( children: [ Positioned.fill( - child: GameWidget(game: _game), + child: GameWidget( + game: game, + initialActiveOverlays: const [PinballGame.playButtonOverlay], + overlayBuilderMap: { + PinballGame.playButtonOverlay: (context, game) { + return Positioned( + bottom: 20, + right: 0, + left: 0, + child: PlayButtonOverlay(game: game), + ); + }, + }, + ), ), const Positioned( top: 8, diff --git a/lib/game/view/widgets/game_over_dialog.dart b/lib/game/view/widgets/game_over_dialog.dart deleted file mode 100644 index e3c5a1e1..00000000 --- a/lib/game/view/widgets/game_over_dialog.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:leaderboard_repository/leaderboard_repository.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball/leaderboard/leaderboard.dart'; -import 'package:pinball_theme/pinball_theme.dart'; - -/// {@template game_over_dialog} -/// [Dialog] displayed when the [PinballGame] is over. -/// {@endtemplate} -class GameOverDialog extends StatelessWidget { - /// {@macro game_over_dialog} - const GameOverDialog({Key? key, required this.score, required this.theme}) - : super(key: key); - - /// Score achieved by the current user. - final int score; - - /// Theme of the current user. - final CharacterTheme theme; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => LeaderboardBloc( - context.read(), - ), - child: GameOverDialogView(score: score, theme: theme), - ); - } -} - -/// {@template game_over_dialog_view} -/// View for showing final score when the game is finished. -/// {@endtemplate} -@visibleForTesting -class GameOverDialogView extends StatefulWidget { - /// {@macro game_over_dialog_view} - const GameOverDialogView({ - Key? key, - required this.score, - required this.theme, - }) : super(key: key); - - /// Score achieved by the current user. - final int score; - - /// Theme of the current user. - final CharacterTheme theme; - - @override - State createState() => _GameOverDialogViewState(); -} - -class _GameOverDialogViewState extends State { - final playerInitialsInputController = TextEditingController(); - - @override - void dispose() { - playerInitialsInputController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - - // TODO(ruimiguel): refactor this view once UI design finished. - return Dialog( - child: SizedBox( - width: 200, - height: 250, - child: Center( - child: Padding( - padding: const EdgeInsets.all(10), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - l10n.gameOver, - style: Theme.of(context).textTheme.headline4, - ), - const SizedBox( - height: 20, - ), - Text( - '${l10n.yourScore} ${widget.score}', - style: Theme.of(context).textTheme.headline6, - ), - const SizedBox( - height: 15, - ), - TextField( - key: const Key('player_initials_text_field'), - controller: playerInitialsInputController, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: l10n.enterInitials, - ), - maxLength: 3, - ), - const SizedBox( - height: 10, - ), - _GameOverDialogActions( - score: widget.score, - theme: widget.theme, - playerInitialsInputController: - playerInitialsInputController, - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _GameOverDialogActions extends StatelessWidget { - const _GameOverDialogActions({ - Key? key, - required this.score, - required this.theme, - required this.playerInitialsInputController, - }) : super(key: key); - - final int score; - final CharacterTheme theme; - final TextEditingController playerInitialsInputController; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - - return BlocBuilder( - builder: (context, state) { - switch (state.status) { - case LeaderboardStatus.loading: - return TextButton( - onPressed: () { - context.read().add( - LeaderboardEntryAdded( - entry: LeaderboardEntryData( - playerInitials: - playerInitialsInputController.text.toUpperCase(), - score: score, - character: theme.toType, - ), - ), - ); - }, - child: Text(l10n.addUser), - ); - case LeaderboardStatus.success: - return TextButton( - onPressed: () => Navigator.of(context).push( - LeaderboardPage.route(theme: theme), - ), - child: Text(l10n.leaderboard), - ); - case LeaderboardStatus.error: - return Text(l10n.error); - } - }, - ); - } -} diff --git a/lib/game/view/widgets/play_button_overlay.dart b/lib/game/view/widgets/play_button_overlay.dart new file mode 100644 index 00000000..6f039124 --- /dev/null +++ b/lib/game/view/widgets/play_button_overlay.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:pinball/game/pinball_game.dart'; +import 'package:pinball/l10n/l10n.dart'; + +/// {@template play_button_overlay} +/// [Widget] that renders the button responsible to starting the game +/// {@endtemplate} +class PlayButtonOverlay extends StatelessWidget { + /// {@macro play_button_overlay} + const PlayButtonOverlay({ + Key? key, + required PinballGame game, + }) : _game = game, + super(key: key); + + final PinballGame _game; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Center( + child: ElevatedButton( + onPressed: _game.gameFlowController.start, + child: Text(l10n.play), + ), + ); + } +} diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart index aa473c64..7e9db5c3 100644 --- a/lib/game/view/widgets/widgets.dart +++ b/lib/game/view/widgets/widgets.dart @@ -1,2 +1,2 @@ export 'game_hud.dart'; -export 'game_over_dialog.dart'; +export 'play_button_overlay.dart'; diff --git a/packages/pinball_components/assets/images/backboard/backboard_game_over.png b/packages/pinball_components/assets/images/backboard/backboard_game_over.png new file mode 100644 index 00000000..70bd4544 Binary files /dev/null and b/packages/pinball_components/assets/images/backboard/backboard_game_over.png differ diff --git a/packages/pinball_components/assets/images/backboard/backboard_scores.png b/packages/pinball_components/assets/images/backboard/backboard_scores.png new file mode 100644 index 00000000..bb591b14 Binary files /dev/null and b/packages/pinball_components/assets/images/backboard/backboard_scores.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index 03dfdd11..4f792641 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -10,6 +10,8 @@ import 'package:flutter/widgets.dart'; class $AssetsImagesGen { const $AssetsImagesGen(); + $AssetsImagesBackboardGen get backboard => const $AssetsImagesBackboardGen(); + /// File path: assets/images/ball.png AssetGenImage get ball => const AssetGenImage('assets/images/ball.png'); @@ -36,6 +38,18 @@ class $AssetsImagesGen { const $AssetsImagesSparkyBumperGen(); } +class $AssetsImagesBackboardGen { + const $AssetsImagesBackboardGen(); + + /// File path: assets/images/backboard/backboard_game_over.png + AssetGenImage get backboardGameOver => + const AssetGenImage('assets/images/backboard/backboard_game_over.png'); + + /// File path: assets/images/backboard/backboard_scores.png + AssetGenImage get backboardScores => + const AssetGenImage('assets/images/backboard/backboard_scores.png'); +} + class $AssetsImagesBaseboardGen { const $AssetsImagesBaseboardGen(); diff --git a/packages/pinball_components/lib/src/components/backboard.dart b/packages/pinball_components/lib/src/components/backboard.dart new file mode 100644 index 00000000..613cbc05 --- /dev/null +++ b/packages/pinball_components/lib/src/components/backboard.dart @@ -0,0 +1,40 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template backboard} +/// The [Backboard] of the pinball machine. +/// {@endtemplate} +class Backboard extends SpriteComponent with HasGameRef { + /// {@macro backboard} + Backboard({ + required Vector2 position, + }) : super( + // TODO(erickzanardo): remove multiply after + // https://github.com/flame-engine/flame/pull/1506 is merged + position: position..clone().multiply(Vector2(1, -1)), + anchor: Anchor.bottomCenter, + ); + + @override + Future onLoad() async { + await waitingMode(); + } + + /// Puts the Backboard in waiting mode, where the scoreboard is shown. + Future waitingMode() async { + final sprite = await gameRef.loadSprite( + Assets.images.backboard.backboardScores.keyName, + ); + size = sprite.originalSize / 10; + this.sprite = sprite; + } + + /// Puts the Backboard in game over mode, where the score input is shown. + Future gameOverMode() async { + final sprite = await gameRef.loadSprite( + Assets.images.backboard.backboardGameOver.keyName, + ); + size = sprite.originalSize / 10; + this.sprite = sprite; + } +} diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index c8020fc4..d7445f29 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -1,3 +1,4 @@ +export 'backboard.dart'; export 'ball.dart'; export 'baseboard.dart'; export 'board_dimensions.dart'; diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index e922f410..f307e908 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -44,6 +44,7 @@ flutter: - assets/images/sparky_bumper/a/ - assets/images/sparky_bumper/b/ - assets/images/sparky_bumper/c/ + - assets/images/backboard/ flutter_gen: line_length: 80 diff --git a/packages/pinball_components/test/src/components/backboard_test.dart b/packages/pinball_components/test/src/components/backboard_test.dart new file mode 100644 index 00000000..2d95cc47 --- /dev/null +++ b/packages/pinball_components/test/src/components/backboard_test.dart @@ -0,0 +1,53 @@ +// ignore_for_file: unawaited_futures + +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 '../../helpers/helpers.dart'; + +void main() { + group('Backboard', () { + final tester = FlameTester(TestGame.new); + + group('on waitingMode', () { + tester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + game.camera.zoom = 2; + game.camera.followVector2(Vector2.zero()); + await game.ensureAdd(Backboard(position: Vector2(0, 15))); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/backboard/waiting.png'), + ); + }, + ); + }); + + group('on gameOverMode', () { + tester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + game.camera.zoom = 2; + game.camera.followVector2(Vector2.zero()); + final backboard = Backboard(position: Vector2(0, 15)); + await game.ensureAdd(backboard); + + await backboard.gameOverMode(); + await game.ready(); + await tester.pump(); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/backboard/game_over.png'), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/ball_test.dart b/packages/pinball_components/test/src/components/ball_test.dart index f2a54c68..248216f4 100644 --- a/packages/pinball_components/test/src/components/ball_test.dart +++ b/packages/pinball_components/test/src/components/ball_test.dart @@ -86,7 +86,7 @@ void main() { final fixture = ball.body.fixtures[0]; expect(fixture.shape.shapeType, equals(ShapeType.circle)); - expect(fixture.shape.radius, equals(2.25)); + expect(fixture.shape.radius, equals(2.065)); }, ); diff --git a/packages/pinball_components/test/src/components/golden/backboard/game_over.png b/packages/pinball_components/test/src/components/golden/backboard/game_over.png new file mode 100644 index 00000000..04a8e3ad Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/backboard/game_over.png differ diff --git a/packages/pinball_components/test/src/components/golden/backboard/waiting.png b/packages/pinball_components/test/src/components/golden/backboard/waiting.png new file mode 100644 index 00000000..25e24a6b Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/backboard/waiting.png differ diff --git a/pubspec.lock b/pubspec.lock index 240c5a9f..fc1e96a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -377,14 +377,14 @@ packages: name: mockingjay url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.3.0" mocktail: dependency: "direct dev" description: name: mocktail url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.3.0" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 161afb85..d497e561 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,8 +35,8 @@ dev_dependencies: flame_test: ^1.3.0 flutter_test: sdk: flutter - mockingjay: ^0.2.0 - mocktail: ^0.2.0 + mockingjay: ^0.3.0 + mocktail: ^0.3.0 very_good_analysis: ^2.4.0 flutter: diff --git a/test/game/components/camera_controller_test.dart b/test/game/components/camera_controller_test.dart new file mode 100644 index 00000000..6af3f594 --- /dev/null +++ b/test/game/components/camera_controller_test.dart @@ -0,0 +1,85 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/camera_controller.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group('CameraController', () { + late FlameGame game; + late CameraController controller; + + setUp(() async { + game = FlameGame()..onGameResize(Vector2(100, 200)); + + controller = CameraController(game); + await game.ensureAdd(controller); + }); + + test('loads correctly', () async { + expect(game.firstChild(), isNotNull); + }); + + test('correctly calculates the zooms', () async { + expect(controller.gameFocus.zoom.toInt(), equals(12)); + expect(controller.backboardFocus.zoom.toInt(), equals(11)); + }); + + test('correctly sets the initial zoom and position', () async { + expect(game.camera.zoom, equals(controller.backboardFocus.zoom)); + expect(game.camera.follow, equals(controller.backboardFocus.position)); + }); + + group('focusOnBoard', () { + test('changes the zoom', () async { + controller.focusOnGame(); + + await game.ready(); + final zoom = game.firstChild(); + expect(zoom, isNotNull); + expect(zoom?.value, equals(controller.gameFocus.zoom)); + }); + + test('moves the camera after the zoom is completed', () async { + controller.focusOnGame(); + await game.ready(); + final cameraZoom = game.firstChild()!; + final future = cameraZoom.completed; + + game.update(10); + game.update(0); // Ensure that the component was removed + + await future; + + expect(game.camera.position, Vector2(-4, -108.8)); + }); + }); + + group('focusOnBackboard', () { + test('changes the zoom', () async { + controller.focusOnBackboard(); + + await game.ready(); + final zoom = game.firstChild(); + expect(zoom, isNotNull); + expect(zoom?.value, equals(controller.backboardFocus.zoom)); + }); + + test('moves the camera after the zoom is completed', () async { + controller.focusOnBackboard(); + await game.ready(); + final cameraZoom = game.firstChild()!; + final future = cameraZoom.completed; + + game.update(10); + game.update(0); // Ensure that the component was removed + + await future; + + expect(game.camera.position, Vector2(-4.5, -109.8)); + }); + }); + }); +} diff --git a/test/game/components/game_flow_controller_test.dart b/test/game/components/game_flow_controller_test.dart new file mode 100644 index 00000000..dc1d9ab8 --- /dev/null +++ b/test/game/components/game_flow_controller_test.dart @@ -0,0 +1,88 @@ +// ignore_for_file: type_annotate_public_apis, prefer_const_constructors + +import 'package:flame/game.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('GameFlowController', () { + group('listenWhen', () { + test('is true when the game over state has changed', () { + final state = GameState( + score: 10, + balls: 0, + activatedBonusLetters: const [], + bonusHistory: const [], + activatedDashNests: const {}, + ); + + final previous = GameState.initial(); + expect( + GameFlowController(MockPinballGame()).listenWhen(previous, state), + isTrue, + ); + }); + }); + + group('onNewState', () { + late PinballGame game; + late Backboard backboard; + late CameraController cameraController; + late GameFlowController gameFlowController; + late ActiveOverlaysNotifier overlays; + + setUp(() { + game = MockPinballGame(); + backboard = MockBackboard(); + cameraController = MockCameraController(); + gameFlowController = GameFlowController(game); + overlays = MockActiveOverlaysNotifier(); + + when(backboard.gameOverMode).thenAnswer((_) async {}); + when(backboard.waitingMode).thenAnswer((_) async {}); + when(cameraController.focusOnBackboard).thenAnswer((_) async {}); + when(cameraController.focusOnGame).thenAnswer((_) async {}); + + when(() => overlays.remove(any())).thenAnswer((_) => true); + + when(game.firstChild).thenReturn(backboard); + when(game.firstChild).thenReturn(cameraController); + when(() => game.overlays).thenReturn(overlays); + }); + + test( + 'changes the backboard and camera correctly when it is a game over', + () { + gameFlowController.onNewState( + GameState( + score: 10, + balls: 0, + activatedBonusLetters: const [], + bonusHistory: const [], + activatedDashNests: const {}, + ), + ); + + verify(backboard.gameOverMode).called(1); + verify(cameraController.focusOnBackboard).called(1); + }, + ); + + test( + 'changes the backboard and camera correctly when it is not a game over', + () { + gameFlowController.onNewState(GameState.initial()); + + verify(backboard.waitingMode).called(1); + verify(cameraController.focusOnGame).called(1); + verify(() => overlays.remove(PinballGame.playButtonOverlay)) + .called(1); + }, + ); + }); + }); +} diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 683b53e8..7a1419fb 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -137,7 +137,7 @@ void main() { ); await tester.pumpApp( - PinballGameView(theme: theme, game: game), + PinballGameView(game: game), gameBloc: gameBloc, ); @@ -150,33 +150,5 @@ void main() { findsOneWidget, ); }); - - testWidgets( - 'renders a game over dialog when the user has lost', - (tester) async { - final gameBloc = MockGameBloc(); - const state = GameState( - score: 0, - balls: 0, - activatedBonusLetters: [], - activatedDashNests: {}, - bonusHistory: [], - ); - - whenListen( - gameBloc, - Stream.value(state), - initialState: GameState.initial(), - ); - - await tester.pumpApp( - PinballGameView(theme: theme, game: game), - gameBloc: gameBloc, - ); - await tester.pump(); - - expect(find.byType(GameOverDialog), findsOneWidget); - }, - ); }); } diff --git a/test/game/view/play_button_overlay_test.dart b/test/game/view/play_button_overlay_test.dart new file mode 100644 index 00000000..020998d4 --- /dev/null +++ b/test/game/view/play_button_overlay_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('PlayButtonOverlay', () { + late PinballGame game; + late GameFlowController gameFlowController; + + setUp(() { + game = MockPinballGame(); + gameFlowController = MockGameFlowController(); + + when(() => game.gameFlowController).thenReturn(gameFlowController); + when(gameFlowController.start).thenAnswer((_) {}); + }); + + testWidgets('renders correctly', (tester) async { + await tester.pumpApp(PlayButtonOverlay(game: game)); + + expect(find.text('Play'), findsOneWidget); + }); + + testWidgets('calls gameFlowController.start when taped', (tester) async { + await tester.pumpApp(PlayButtonOverlay(game: game)); + + await tester.tap(find.text('Play')); + await tester.pump(); + + verify(gameFlowController.start).called(1); + }); + }); +} diff --git a/test/game/view/widgets/game_over_dialog_test.dart b/test/game/view/widgets/game_over_dialog_test.dart deleted file mode 100644 index 814a7a45..00000000 --- a/test/game/view/widgets/game_over_dialog_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'dart:async'; - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:leaderboard_repository/leaderboard_repository.dart'; -import 'package:mockingjay/mockingjay.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball/leaderboard/leaderboard.dart'; -import 'package:pinball_theme/pinball_theme.dart'; - -import '../../../helpers/helpers.dart'; - -void main() { - group('GameOverDialog', () { - testWidgets('renders GameOverDialogView', (tester) async { - await tester.pumpApp( - GameOverDialog( - score: 1000, - theme: DashTheme(), - ), - ); - - expect(find.byType(GameOverDialogView), findsOneWidget); - }); - - group('GameOverDialogView', () { - late LeaderboardBloc leaderboardBloc; - - final leaderboard = [ - LeaderboardEntry( - rank: '1', - playerInitials: 'ABC', - score: 5000, - character: DashTheme().characterAsset, - ), - ]; - final entryData = LeaderboardEntryData( - playerInitials: 'VGV', - score: 10000, - character: CharacterType.dash, - ); - - setUp(() { - leaderboardBloc = MockLeaderboardBloc(); - whenListen( - leaderboardBloc, - const Stream.empty(), - initialState: const LeaderboardState.initial(), - ); - }); - - testWidgets('renders input text view when bloc emits [loading]', - (tester) async { - final l10n = await AppLocalizations.delegate.load(Locale('en')); - - await tester.pumpApp( - BlocProvider.value( - value: leaderboardBloc, - child: GameOverDialogView( - score: entryData.score, - theme: entryData.character.toTheme, - ), - ), - ); - - expect(find.widgetWithText(TextButton, l10n.addUser), findsOneWidget); - }); - - testWidgets('renders error view when bloc emits [error]', (tester) async { - final l10n = await AppLocalizations.delegate.load(Locale('en')); - - whenListen( - leaderboardBloc, - const Stream.empty(), - initialState: LeaderboardState.initial() - .copyWith(status: LeaderboardStatus.error), - ); - - await tester.pumpApp( - BlocProvider.value( - value: leaderboardBloc, - child: GameOverDialogView( - score: entryData.score, - theme: entryData.character.toTheme, - ), - ), - ); - - expect(find.text(l10n.error), findsOneWidget); - }); - - testWidgets('renders success view when bloc emits [success]', - (tester) async { - final l10n = await AppLocalizations.delegate.load(Locale('en')); - - whenListen( - leaderboardBloc, - const Stream.empty(), - initialState: LeaderboardState( - status: LeaderboardStatus.success, - ranking: LeaderboardRanking(ranking: 1, outOf: 2), - leaderboard: leaderboard, - ), - ); - - await tester.pumpApp( - BlocProvider.value( - value: leaderboardBloc, - child: GameOverDialogView( - score: entryData.score, - theme: entryData.character.toTheme, - ), - ), - ); - - expect( - find.widgetWithText(TextButton, l10n.leaderboard), - findsOneWidget, - ); - }); - - testWidgets('adds LeaderboardEntryAdded when tap on add user button', - (tester) async { - final l10n = await AppLocalizations.delegate.load(Locale('en')); - - whenListen( - leaderboardBloc, - const Stream.empty(), - initialState: LeaderboardState.initial(), - ); - - await tester.pumpApp( - BlocProvider.value( - value: leaderboardBloc, - child: GameOverDialogView( - score: entryData.score, - theme: entryData.character.toTheme, - ), - ), - ); - - await tester.enterText( - find.byKey(const Key('player_initials_text_field')), - entryData.playerInitials, - ); - - final button = find.widgetWithText(TextButton, l10n.addUser); - await tester.ensureVisible(button); - await tester.tap(button); - - verify( - () => leaderboardBloc.add(LeaderboardEntryAdded(entry: entryData)), - ).called(1); - }); - - testWidgets('navigates to LeaderboardPage when tap on leaderboard button', - (tester) async { - final l10n = await AppLocalizations.delegate.load(Locale('en')); - final navigator = MockNavigator(); - when(() => navigator.push(any())).thenAnswer((_) async {}); - whenListen( - leaderboardBloc, - const Stream.empty(), - initialState: LeaderboardState( - status: LeaderboardStatus.success, - ranking: LeaderboardRanking(ranking: 1, outOf: 2), - leaderboard: leaderboard, - ), - ); - - await tester.pumpApp( - BlocProvider.value( - value: leaderboardBloc, - child: GameOverDialogView( - score: entryData.score, - theme: entryData.character.toTheme, - ), - ), - navigator: navigator, - ); - - final button = find.widgetWithText(TextButton, l10n.leaderboard); - await tester.ensureVisible(button); - await tester.tap(button); - - verify(() => navigator.push(any())).called(1); - }); - }); - }); -} diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 2da91a25..941da872 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -1,4 +1,5 @@ import 'package:flame/components.dart'; +import 'package:flame/game.dart'; import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/foundation.dart'; @@ -76,3 +77,12 @@ class MockDashNestBumper extends Mock implements DashNestBumper {} class MockPinballAudio extends Mock implements PinballAudio {} class MockAssetsManagerCubit extends Mock implements AssetsManagerCubit {} + +class MockBackboard extends Mock implements Backboard {} + +class MockCameraController extends Mock implements CameraController {} + +class MockActiveOverlaysNotifier extends Mock + implements ActiveOverlaysNotifier {} + +class MockGameFlowController extends Mock implements GameFlowController {}