From b9c2f3a54f4a6477c6421a18a9f7d67d9dd1e26e Mon Sep 17 00:00:00 2001 From: arturplaczek <33895544+arturplaczek@users.noreply.github.com> Date: Wed, 27 Apr 2022 15:00:45 +0200 Subject: [PATCH 1/2] feat: score widget with animation (#206) --- .../{android.png => android_spaceship.png} | Bin .../{dino.png => dino_chomp.png} | Bin .../{google.png => google_word.png} | Bin assets/images/score/mini_score_background.png | Bin 0 -> 11489 bytes lib/game/bloc/game_state.dart | 6 + lib/game/view/pinball_game_page.dart | 19 ++- lib/game/view/widgets/bonus_animation.dart | 100 +++++++++---- lib/game/view/widgets/game_hud.dart | 134 +++++++++++++---- .../view/widgets/round_count_display.dart | 70 +++++++++ lib/game/view/widgets/score_view.dart | 86 +++++++++++ lib/game/view/widgets/widgets.dart | 2 + lib/gen/assets.gen.dart | 27 ++-- lib/l10n/arb/app_en.arb | 4 + lib/theme/app_text_style.dart | 2 +- packages/pinball_components/lib/gen/gen.dart | 1 + pubspec.yaml | 1 + test/game/view/game_hud_test.dart | 83 ----------- test/game/view/pinball_game_page_test.dart | 46 +++++- .../view/widgets/bonus_animation_test.dart | 84 +++++++++-- test/game/view/widgets/game_hud_test.dart | 137 ++++++++++++++++++ .../widgets/round_count_display_test.dart | 132 +++++++++++++++++ test/game/view/widgets/score_view_test.dart | 81 +++++++++++ test/helpers/mocks.dart | 3 + test/helpers/pump_app.dart | 5 + 24 files changed, 857 insertions(+), 166 deletions(-) rename assets/images/bonus_animation/{android.png => android_spaceship.png} (100%) rename assets/images/bonus_animation/{dino.png => dino_chomp.png} (100%) rename assets/images/bonus_animation/{google.png => google_word.png} (100%) create mode 100644 assets/images/score/mini_score_background.png create mode 100644 lib/game/view/widgets/round_count_display.dart create mode 100644 lib/game/view/widgets/score_view.dart delete mode 100644 test/game/view/game_hud_test.dart create mode 100644 test/game/view/widgets/game_hud_test.dart create mode 100644 test/game/view/widgets/round_count_display_test.dart create mode 100644 test/game/view/widgets/score_view_test.dart diff --git a/assets/images/bonus_animation/android.png b/assets/images/bonus_animation/android_spaceship.png similarity index 100% rename from assets/images/bonus_animation/android.png rename to assets/images/bonus_animation/android_spaceship.png diff --git a/assets/images/bonus_animation/dino.png b/assets/images/bonus_animation/dino_chomp.png similarity index 100% rename from assets/images/bonus_animation/dino.png rename to assets/images/bonus_animation/dino_chomp.png diff --git a/assets/images/bonus_animation/google.png b/assets/images/bonus_animation/google_word.png similarity index 100% rename from assets/images/bonus_animation/google.png rename to assets/images/bonus_animation/google_word.png diff --git a/assets/images/score/mini_score_background.png b/assets/images/score/mini_score_background.png new file mode 100644 index 0000000000000000000000000000000000000000..781f734962be8dd3235feecfb0dbec836dd2b229 GIT binary patch literal 11489 zcmeHtXH-+$);2|u4k8^yy0k!m07(Fq-iwIzVju~XQ1{<-7(?!TKcvd7+Q&H2nZpZV-H*4{~kmWCoJ5d#qx78a?plAJac z7WN_LJeB|#a~xu@eu#xd*x;k1@1_m&WN~tFL|WS+Slqmw5G)8UYa|wy*UWaRfy*+O zGW4tS3kc5fcd$PDIlk-V86^v1Sl3g>y*-Aikei~J1#OHFMPE#5qrZKBHZmNRU=i`? z;AbDLQS;Dw+hzyD_KACEQs>UdMGG%&bOS%+{`^_m@)_vlT;3@lZP>&MIDF;vVc{-S z!2Z}*Iulfe6}*|gQGu4-atzm1sCZ2unv{{HXm#)`=7QtwlBc<;&bu?^8G7gTXU0Hz z%Wb!Q>&o{gMBua9;U^A~ddqE6?-FfSH%&S~-7MK#hdwZ?dbuZP=LtsMs#Zsi|{uQ-q}OVp|h6S-5(C?{-ATD-9z*!p2c&!#5mox4+PxN`5o{*hH5@ z@fvrS?j}wS(ik|t4!0M^mqlWg7CS_w;^T1LkkI<*v&QxX&i9&C8R=NxT~n@qFEvok zX`J&7PbE&$2{u~!{<5rS)1oYaP=2f|p=isxMYvEQw{kUCa+?ZkUxKy<=clcjG1h?$ zm-n1g=8^2yp_Hn;%?a9k!h9KglZmADVzb5vfMOaWo90AaiJb{Qa;f7l zDDcO@yeQ~sZ(?CKtUnc*h4>s&1}I80gY_lqnj${Y+R&CiSm+E)Yu2h|QsxSfpC7f;&OT5d=WeoD?BM~evhcHc;=Sh?fTI)J0p7>vJYCUJDi+}FP<2P zh*eM}NroHSkIW~FD$K}<*ew}v+7!m64@yaOi%4tHKFJ@uG`=;>-|odk_8~H7bLC9m zW6<)XN6sfOsZpUMJ8T_Ah`P%x?vzU}o@WqZ`;tSM2`~yRJbD)}b&Gy810QhgeOgxy zHmlkA(46;;NMyOn)&ZaWlR^1drRIaCh}uR2omo4%qx(kmN!nk-3MbPj-*MduN z6A1^A>gD?jcKJ!hGy~rl#n`YTGWYA13fV5;jfK($O>IVfgJLEJIWCov?}BdfPS%T; z`HBO&x>`;egK6uEjMm8XO?sl$*JhJhk;M6W2yHu)NgS!J>Q+v^x z>{&%B%P_e≫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 wX$5ynN{mv2u6u@f1 bloc.state.status == StartGameStatus.play, + ); + final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; + final screenWidth = MediaQuery.of(context).size.width; + final leftMargin = (screenWidth / 2) - (gameWidgetWidth / 1.8); + return Stack( children: [ Positioned.fill( @@ -131,10 +139,13 @@ class PinballGameLoadedView extends StatelessWidget { }, ), ), - const Positioned( - top: 8, - left: 8, - child: GameHud(), + Positioned( + top: 16, + left: leftMargin, + child: Visibility( + visible: isPlaying, + child: const GameHud(), + ), ), ], ); diff --git a/lib/game/view/widgets/bonus_animation.dart b/lib/game/view/widgets/bonus_animation.dart index 39cee913..da67e1aa 100644 --- a/lib/game/view/widgets/bonus_animation.dart +++ b/lib/game/view/widgets/bonus_animation.dart @@ -1,19 +1,23 @@ -// ignore_for_file: public_member_api_docs - import 'package:flame/flame.dart'; import 'package:flame/sprite.dart'; -import 'package:flame/widgets.dart'; import 'package:flutter/material.dart' hide Image; import 'package:pinball/gen/assets.gen.dart'; +import 'package:pinball_flame/pinball_flame.dart'; -class BonusAnimation extends StatelessWidget { +/// {@template bonus_animation} +/// [Widget] that displays bonus animations. +/// {@endtemplate} +class BonusAnimation extends StatefulWidget { + /// {@macro bonus_animation} const BonusAnimation._( - this.imagePath, { + String imagePath, { VoidCallback? onCompleted, Key? key, - }) : _onCompleted = onCompleted, + }) : _imagePath = imagePath, + _onCompleted = onCompleted, super(key: key); + /// [Widget] that displays the dash nest animation. BonusAnimation.dashNest({ Key? key, VoidCallback? onCompleted, @@ -23,6 +27,7 @@ class BonusAnimation extends StatelessWidget { key: key, ); + /// [Widget] that displays the sparky turbo charge animation. BonusAnimation.sparkyTurboCharge({ Key? key, VoidCallback? onCompleted, @@ -32,56 +37,94 @@ class BonusAnimation extends StatelessWidget { key: key, ); - BonusAnimation.dino({ + /// [Widget] that displays the dino chomp animation. + BonusAnimation.dinoChomp({ Key? key, VoidCallback? onCompleted, }) : this._( - Assets.images.bonusAnimation.dino.keyName, + Assets.images.bonusAnimation.dinoChomp.keyName, onCompleted: onCompleted, key: key, ); - BonusAnimation.android({ + /// [Widget] that displays the android spaceship animation. + BonusAnimation.androidSpaceship({ Key? key, VoidCallback? onCompleted, }) : this._( - Assets.images.bonusAnimation.android.keyName, + Assets.images.bonusAnimation.androidSpaceship.keyName, onCompleted: onCompleted, key: key, ); - BonusAnimation.google({ + /// [Widget] that displays the google word animation. + BonusAnimation.googleWord({ Key? key, VoidCallback? onCompleted, }) : this._( - Assets.images.bonusAnimation.google.keyName, + Assets.images.bonusAnimation.googleWord.keyName, onCompleted: onCompleted, key: key, ); - final String imagePath; + final String _imagePath; final VoidCallback? _onCompleted; - static Future loadAssets() { + /// Returns a list of assets to be loaded for animations. + static List loadAssets() { Flame.images.prefix = ''; - return Flame.images.loadAll([ - Assets.images.bonusAnimation.dashNest.keyName, - Assets.images.bonusAnimation.sparkyTurboCharge.keyName, - Assets.images.bonusAnimation.dino.keyName, - Assets.images.bonusAnimation.android.keyName, - Assets.images.bonusAnimation.google.keyName, - ]); + return [ + Flame.images.load(Assets.images.bonusAnimation.dashNest.keyName), + Flame.images.load(Assets.images.bonusAnimation.sparkyTurboCharge.keyName), + Flame.images.load(Assets.images.bonusAnimation.dinoChomp.keyName), + Flame.images.load(Assets.images.bonusAnimation.androidSpaceship.keyName), + Flame.images.load(Assets.images.bonusAnimation.googleWord.keyName), + ]; + } + + @override + State createState() => _BonusAnimationState(); +} + +class _BonusAnimationState extends State + with TickerProviderStateMixin { + late SpriteAnimationController controller; + late SpriteAnimation animation; + bool shouldRunBuildCallback = true; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + // When the animation is overwritten by another animation, we need to stop + // the callback in the build method as it will break the new animation. + // Otherwise we need to set up a new callback when a new animation starts to + // show the score view at the end of the animation. + @override + void didUpdateWidget(BonusAnimation oldWidget) { + shouldRunBuildCallback = oldWidget._imagePath == widget._imagePath; + + Future.delayed( + Duration(seconds: animation.totalDuration().ceil()), + () { + widget._onCompleted?.call(); + }, + ); + + super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { final spriteSheet = SpriteSheet.fromColumnsAndRows( - image: Flame.images.fromCache(imagePath), + image: Flame.images.fromCache(widget._imagePath), columns: 8, rows: 9, ); - final animation = spriteSheet.createAnimation( + animation = spriteSheet.createAnimation( row: 0, stepTime: 1 / 24, to: spriteSheet.rows * spriteSheet.columns, @@ -91,15 +134,22 @@ class BonusAnimation extends StatelessWidget { Future.delayed( Duration(seconds: animation.totalDuration().ceil()), () { - _onCompleted?.call(); + if (shouldRunBuildCallback) { + widget._onCompleted?.call(); + } }, ); + controller = SpriteAnimationController( + animation: animation, + vsync: this, + )..forward(); + return SizedBox( width: double.infinity, height: double.infinity, child: SpriteAnimationWidget( - animation: animation, + controller: controller, ), ); } diff --git a/lib/game/view/widgets/game_hud.dart b/lib/game/view/widgets/game_hud.dart index 00eedd2b..3623e21f 100644 --- a/lib/game/view/widgets/game_hud.dart +++ b/lib/game/view/widgets/game_hud.dart @@ -1,46 +1,122 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/gen/gen.dart'; +import 'package:pinball/theme/app_colors.dart'; /// {@template game_hud} -/// Overlay of a [PinballGame] that displays the current [GameState.score] and -/// [GameState.balls]. +/// Overlay on the [PinballGame]. +/// +/// Displays the current [GameState.score], [GameState.balls] and animates when +/// the player gets a [GameBonus]. /// {@endtemplate} -class GameHud extends StatelessWidget { +class GameHud extends StatefulWidget { /// {@macro game_hud} const GameHud({Key? key}) : super(key: key); + @override + State createState() => _GameHudState(); +} + +class _GameHudState extends State { + bool showAnimation = false; + + /// Ratio from sprite frame (width 500, height 144) w / h = ratio + static const _ratio = 3.47; + static const _width = 265.0; + @override Widget build(BuildContext context) { - final state = context.watch().state; - - return Container( - color: Colors.redAccent, - width: 200, - height: 100, - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${state.score}', - style: Theme.of(context).textTheme.headline3, + final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver); + + return _ScoreViewDecoration( + child: SizedBox( + height: _width / _ratio, + width: _width, + child: BlocListener( + listenWhen: (previous, current) => + previous.bonusHistory.length != current.bonusHistory.length, + listener: (_, __) => setState(() => showAnimation = true), + child: AnimatedSwitcher( + duration: kThemeAnimationDuration, + child: showAnimation && !isGameOver + ? _AnimationView( + onComplete: () { + if (mounted) { + setState(() => showAnimation = false); + } + }, + ) + : const ScoreView(), ), - Wrap( - direction: Axis.vertical, - children: [ - for (var i = 0; i < state.balls; i++) - const Padding( - padding: EdgeInsets.only(top: 6, right: 6), - child: CircleAvatar( - radius: 8, - backgroundColor: Colors.black, - ), - ), - ], + ), + ), + ); + } +} + +class _ScoreViewDecoration extends StatelessWidget { + const _ScoreViewDecoration({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) { + const radius = BorderRadius.all(Radius.circular(12)); + const boardWidth = 5.0; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all( + color: AppColors.white, + width: boardWidth, + ), + image: DecorationImage( + fit: BoxFit.cover, + image: AssetImage( + Assets.images.score.miniScoreBackground.path, ), - ], + ), ), + child: Padding( + padding: const EdgeInsets.all(boardWidth - 1), + child: ClipRRect( + borderRadius: radius, + child: child, + ), + ), + ); + } +} + +class _AnimationView extends StatelessWidget { + const _AnimationView({ + Key? key, + required this.onComplete, + }) : super(key: key); + + final VoidCallback onComplete; + + @override + Widget build(BuildContext context) { + final lastBonus = context.select( + (GameBloc bloc) => bloc.state.bonusHistory.last, ); + switch (lastBonus) { + case GameBonus.dashNest: + return BonusAnimation.dashNest(onCompleted: onComplete); + case GameBonus.sparkyTurboCharge: + return BonusAnimation.sparkyTurboCharge(onCompleted: onComplete); + case GameBonus.dinoChomp: + return BonusAnimation.dinoChomp(onCompleted: onComplete); + case GameBonus.googleWord: + return BonusAnimation.googleWord(onCompleted: onComplete); + case GameBonus.androidSpaceship: + return BonusAnimation.androidSpaceship(onCompleted: onComplete); + } } } diff --git a/lib/game/view/widgets/round_count_display.dart b/lib/game/view/widgets/round_count_display.dart new file mode 100644 index 00000000..98776764 --- /dev/null +++ b/lib/game/view/widgets/round_count_display.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/theme/theme.dart'; + +/// {@template round_count_display} +/// Colored square indicating if a round is available. +/// {@endtemplate} +class RoundCountDisplay extends StatelessWidget { + /// {@macro round_count_display} + const RoundCountDisplay({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + // TODO(arturplaczek): refactor when GameState handle balls and rounds and + // select state.rounds property instead of state.ball + final balls = context.select((GameBloc bloc) => bloc.state.balls); + + return Row( + children: [ + Text( + l10n.rounds, + style: AppTextStyle.subtitle1.copyWith( + color: AppColors.orange, + ), + ), + const SizedBox(width: 8), + Row( + children: [ + RoundIndicator(isActive: balls >= 1), + RoundIndicator(isActive: balls >= 2), + RoundIndicator(isActive: balls >= 3), + ], + ), + ], + ); + } +} + +/// {@template round_indicator} +/// [Widget] that displays the round indicator. +/// {@endtemplate} +@visibleForTesting +class RoundIndicator extends StatelessWidget { + /// {@macro round_indicator} + const RoundIndicator({ + Key? key, + required this.isActive, + }) : super(key: key); + + /// A value that describes whether the indicator is active. + final bool isActive; + + @override + Widget build(BuildContext context) { + final color = isActive ? AppColors.orange : AppColors.orange.withAlpha(128); + const size = 8.0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Container( + color: color, + height: size, + width: size, + ), + ); + } +} diff --git a/lib/game/view/widgets/score_view.dart b/lib/game/view/widgets/score_view.dart new file mode 100644 index 00000000..288ea05c --- /dev/null +++ b/lib/game/view/widgets/score_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/theme/theme.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template score_view} +/// [Widget] that displays the score. +/// {@endtemplate} +class ScoreView extends StatelessWidget { + /// {@macro score_view} + const ScoreView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: AnimatedSwitcher( + duration: kThemeAnimationDuration, + child: isGameOver ? const _GameOver() : const _ScoreDisplay(), + ), + ); + } +} + +class _GameOver extends StatelessWidget { + const _GameOver({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Text( + l10n.gameOver, + style: AppTextStyle.headline1.copyWith( + color: AppColors.white, + ), + ); + } +} + +class _ScoreDisplay extends StatelessWidget { + const _ScoreDisplay({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + l10n.score.toLowerCase(), + style: AppTextStyle.subtitle1.copyWith( + color: AppColors.orange, + ), + ), + const _ScoreText(), + const RoundCountDisplay(), + ], + ); + } +} + +class _ScoreText extends StatelessWidget { + const _ScoreText({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final score = context.select((GameBloc bloc) => bloc.state.score); + + return Text( + score.formatScore(), + style: AppTextStyle.headline1.copyWith( + color: AppColors.white, + ), + ); + } +} diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart index 674577af..5d1fccf8 100644 --- a/lib/game/view/widgets/widgets.dart +++ b/lib/game/view/widgets/widgets.dart @@ -1,3 +1,5 @@ export 'bonus_animation.dart'; export 'game_hud.dart'; export 'play_button_overlay.dart'; +export 'round_count_display.dart'; +export 'score_view.dart'; diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 3e52e399..f5b935a5 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -14,26 +14,27 @@ class $AssetsImagesGen { const $AssetsImagesBonusAnimationGen(); $AssetsImagesComponentsGen get components => const $AssetsImagesComponentsGen(); + $AssetsImagesScoreGen get score => const $AssetsImagesScoreGen(); } class $AssetsImagesBonusAnimationGen { const $AssetsImagesBonusAnimationGen(); - /// File path: assets/images/bonus_animation/android.png - AssetGenImage get android => - const AssetGenImage('assets/images/bonus_animation/android.png'); + /// File path: assets/images/bonus_animation/android_spaceship.png + AssetGenImage get androidSpaceship => const AssetGenImage( + 'assets/images/bonus_animation/android_spaceship.png'); /// File path: assets/images/bonus_animation/dash_nest.png AssetGenImage get dashNest => const AssetGenImage('assets/images/bonus_animation/dash_nest.png'); - /// File path: assets/images/bonus_animation/dino.png - AssetGenImage get dino => - const AssetGenImage('assets/images/bonus_animation/dino.png'); + /// File path: assets/images/bonus_animation/dino_chomp.png + AssetGenImage get dinoChomp => + const AssetGenImage('assets/images/bonus_animation/dino_chomp.png'); - /// File path: assets/images/bonus_animation/google.png - AssetGenImage get google => - const AssetGenImage('assets/images/bonus_animation/google.png'); + /// File path: assets/images/bonus_animation/google_word.png + AssetGenImage get googleWord => + const AssetGenImage('assets/images/bonus_animation/google_word.png'); /// File path: assets/images/bonus_animation/sparky_turbo_charge.png AssetGenImage get sparkyTurboCharge => const AssetGenImage( @@ -48,6 +49,14 @@ class $AssetsImagesComponentsGen { const AssetGenImage('assets/images/components/background.png'); } +class $AssetsImagesScoreGen { + const $AssetsImagesScoreGen(); + + /// File path: assets/images/score/mini_score_background.png + AssetGenImage get miniScoreBackground => + const AssetGenImage('assets/images/score/mini_score_background.png'); +} + class Assets { Assets._(); diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index c551535f..9655d8be 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -75,5 +75,9 @@ "enterInitials": "Enter your initials", "@enterInitials": { "description": "Text displayed on the ending dialog when game finishes to ask the user for his initials" + }, + "rounds": "Ball Ct:", + "@rounds": { + "description": "Text displayed on the scoreboard widget to indicate rounds left" } } diff --git a/lib/theme/app_text_style.dart b/lib/theme/app_text_style.dart index 068f1eb9..8104ca11 100644 --- a/lib/theme/app_text_style.dart +++ b/lib/theme/app_text_style.dart @@ -5,7 +5,7 @@ import 'package:pinball/theme/theme.dart'; import 'package:pinball_components/pinball_components.dart'; const _fontPackage = 'pinball_components'; -const _primaryFontFamily = PinballFonts.pixeloidSans; +const _primaryFontFamily = FontFamily.pixeloidSans; abstract class AppTextStyle { static const headline1 = TextStyle( diff --git a/packages/pinball_components/lib/gen/gen.dart b/packages/pinball_components/lib/gen/gen.dart index 0171b231..ada8b777 100644 --- a/packages/pinball_components/lib/gen/gen.dart +++ b/packages/pinball_components/lib/gen/gen.dart @@ -1,2 +1,3 @@ export 'assets.gen.dart'; +export 'fonts.gen.dart'; export 'pinball_fonts.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index f17ea07a..3b950c27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ flutter: assets: - assets/images/components/ - assets/images/bonus_animation/ + - assets/images/score/ flutter_gen: line_length: 80 diff --git a/test/game/view/game_hud_test.dart b/test/game/view/game_hud_test.dart deleted file mode 100644 index cdc56832..00000000 --- a/test/game/view/game_hud_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -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'; -import '../../helpers/helpers.dart'; - -void main() { - group('GameHud', () { - late GameBloc gameBloc; - const initialState = GameState( - score: 10, - balls: 2, - bonusHistory: [], - ); - - void _mockState(GameState state) { - whenListen( - gameBloc, - Stream.value(state), - initialState: state, - ); - } - - Future _pumpHud(WidgetTester tester) async { - await tester.pumpApp( - GameHud(), - gameBloc: gameBloc, - ); - } - - setUp(() { - gameBloc = MockGameBloc(); - _mockState(initialState); - }); - - testWidgets( - 'renders the current score', - (tester) async { - await _pumpHud(tester); - expect(find.text(initialState.score.toString()), findsOneWidget); - }, - ); - - testWidgets( - 'renders the current ball number', - (tester) async { - await _pumpHud(tester); - expect( - find.byType(CircleAvatar), - findsNWidgets(initialState.balls), - ); - }, - ); - - testWidgets('updates the score', (tester) async { - await _pumpHud(tester); - expect(find.text(initialState.score.toString()), findsOneWidget); - - _mockState(initialState.copyWith(score: 20)); - - await tester.pump(); - expect(find.text('20'), findsOneWidget); - }); - - testWidgets('updates the ball number', (tester) async { - await _pumpHud(tester); - expect( - find.byType(CircleAvatar), - findsNWidgets(initialState.balls), - ); - - _mockState(initialState.copyWith(balls: 1)); - - await tester.pump(); - expect( - find.byType(CircleAvatar), - findsNWidgets(1), - ); - }); - }); -} diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index bf6391d9..191d3676 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import '../../helpers/helpers.dart'; @@ -79,6 +80,7 @@ void main() { 'renders PinballGameLoadedView after resources have been loaded', (tester) async { final assetsManagerCubit = MockAssetsManagerCubit(); + final startGameBloc = MockStartGameBloc(); final loadedAssetsState = AssetsManagerState( loadables: [Future.value()], @@ -89,6 +91,11 @@ void main() { Stream.value(loadedAssetsState), initialState: loadedAssetsState, ); + whenListen( + startGameBloc, + Stream.value(StartGameState.initial()), + initialState: StartGameState.initial(), + ); await tester.pumpApp( PinballGameView( @@ -97,6 +104,7 @@ void main() { assetsManagerCubit: assetsManagerCubit, characterThemeCubit: characterThemeCubit, gameBloc: gameBloc, + startGameBloc: startGameBloc, ); await tester.pump(); @@ -160,27 +168,59 @@ void main() { }); group('PinballGameView', () { + final gameBloc = MockGameBloc(); + final startGameBloc = MockStartGameBloc(); + setUp(() async { await Future.wait(game.preLoadAssets()); - }); - testWidgets('renders game and a hud', (tester) async { - final gameBloc = MockGameBloc(); whenListen( gameBloc, Stream.value(const GameState.initial()), initialState: const GameState.initial(), ); + whenListen( + startGameBloc, + Stream.value(StartGameState.initial()), + initialState: StartGameState.initial(), + ); + }); + + testWidgets('renders game', (tester) async { await tester.pumpApp( PinballGameView(game: game), gameBloc: gameBloc, + startGameBloc: startGameBloc, ); expect( find.byWidgetPredicate((w) => w is GameWidget), findsOneWidget, ); + expect( + find.byType(GameHud), + findsNothing, + ); + }); + + testWidgets('renders a hud on play state', (tester) async { + final startGameState = StartGameState.initial().copyWith( + status: StartGameStatus.play, + ); + + whenListen( + startGameBloc, + Stream.value(startGameState), + initialState: startGameState, + ); + + await tester.pumpApp( + PinballGameView(game: game), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + expect( find.byType(GameHud), findsOneWidget, diff --git a/test/game/view/widgets/bonus_animation_test.dart b/test/game/view/widgets/bonus_animation_test.dart index 9c23ae0d..aa5a5b83 100644 --- a/test/game/view/widgets/bonus_animation_test.dart +++ b/test/game/view/widgets/bonus_animation_test.dart @@ -1,13 +1,13 @@ -import 'dart:async'; +// ignore_for_file: invalid_use_of_protected_member import 'dart:ui' as ui; import 'package:flame/assets.dart'; -import 'package:flame/widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/view/widgets/bonus_animation.dart'; +import 'package:pinball_flame/pinball_flame.dart'; import '../../../helpers/helpers.dart'; @@ -15,11 +15,15 @@ class MockImages extends Mock implements Images {} class MockImage extends Mock implements ui.Image {} +class MockCallback extends Mock { + void call(); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { - await BonusAnimation.loadAssets(); + await Future.wait(BonusAnimation.loadAssets()); }); group('loads SpriteAnimationWidget correctly for', () { @@ -32,9 +36,9 @@ void main() { expect(find.byType(SpriteAnimationWidget), findsOneWidget); }); - testWidgets('dino', (tester) async { + testWidgets('dinoChomp', (tester) async { await tester.pumpApp( - BonusAnimation.dino(), + BonusAnimation.dinoChomp(), ); await tester.pump(); @@ -50,18 +54,18 @@ void main() { expect(find.byType(SpriteAnimationWidget), findsOneWidget); }); - testWidgets('google', (tester) async { + testWidgets('googleWord', (tester) async { await tester.pumpApp( - BonusAnimation.google(), + BonusAnimation.googleWord(), ); await tester.pump(); expect(find.byType(SpriteAnimationWidget), findsOneWidget); }); - testWidgets('android', (tester) async { + testWidgets('androidSpaceship', (tester) async { await tester.pumpApp( - BonusAnimation.android(), + BonusAnimation.androidSpaceship(), ); await tester.pump(); @@ -74,14 +78,14 @@ void main() { // https://github.com/flame-engine/flame/issues/1543 testWidgets('called onCompleted callback at the end of animation ', (tester) async { - final completer = Completer(); + final callback = MockCallback(); await tester.runAsync(() async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: BonusAnimation.dashNest( - onCompleted: completer.complete, + onCompleted: callback.call, ), ), ), @@ -93,7 +97,63 @@ void main() { await tester.pump(); - expect(completer.isCompleted, isTrue); + verify(callback.call).called(1); + }); + }); + + testWidgets('called onCompleted callback at the end of animation ', + (tester) async { + final callback = MockCallback(); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BonusAnimation.dashNest( + onCompleted: callback.call, + ), + ), + ), + ); + + await tester.pump(); + + await Future.delayed(const Duration(seconds: 4)); + + await tester.pump(); + + verify(callback.call).called(1); + }); + }); + + testWidgets('called onCompleted once when animation changed', (tester) async { + final callback = MockCallback(); + final secondAnimation = BonusAnimation.sparkyTurboCharge( + onCompleted: callback.call, + ); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BonusAnimation.dashNest( + onCompleted: callback.call, + ), + ), + ), + ); + + await tester.pump(); + + tester + .state(find.byType(BonusAnimation)) + .didUpdateWidget(secondAnimation); + + await Future.delayed(const Duration(seconds: 4)); + + await tester.pump(); + + verify(callback.call).called(1); }); }); } diff --git a/test/game/view/widgets/game_hud_test.dart b/test/game/view/widgets/game_hud_test.dart new file mode 100644 index 00000000..f8307b05 --- /dev/null +++ b/test/game/view/widgets/game_hud_test.dart @@ -0,0 +1,137 @@ +// 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_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_components/pinball_components.dart'; +import '../../../helpers/helpers.dart'; + +void main() { + group('GameHud', () { + late GameBloc gameBloc; + + const initialState = GameState( + score: 1000, + balls: 2, + bonusHistory: [], + ); + + setUp(() async { + gameBloc = MockGameBloc(); + await Future.wait(BonusAnimation.loadAssets()); + + whenListen( + gameBloc, + Stream.value(initialState), + initialState: initialState, + ); + }); + + // We cannot use pumpApp when we are testing animation because + // animation tests needs to be run and check in tester.runAsync + Future _pumpAppWithWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BlocProvider.value( + value: gameBloc, + child: GameHud(), + ), + ), + ), + ); + } + + group('renders ScoreView widget', () { + testWidgets( + 'with the score', + (tester) async { + await tester.pumpApp( + GameHud(), + gameBloc: gameBloc, + ); + + expect(find.text(initialState.score.formatScore()), findsOneWidget); + }, + ); + + testWidgets( + 'on game over', + (tester) async { + final state = initialState.copyWith( + bonusHistory: [GameBonus.dashNest], + balls: 0, + ); + + whenListen( + gameBloc, + Stream.value(state), + initialState: initialState, + ); + await tester.pumpApp( + GameHud(), + gameBloc: gameBloc, + ); + + expect(find.byType(ScoreView), findsOneWidget); + expect(find.byType(BonusAnimation), findsNothing); + }, + ); + }); + + for (final gameBonus in GameBonus.values) { + testWidgets('renders BonusAnimation for $gameBonus', (tester) async { + await tester.runAsync(() async { + final state = initialState.copyWith( + bonusHistory: [gameBonus], + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: initialState, + ); + + await _pumpAppWithWidget(tester); + await tester.pump(); + + expect(find.byType(BonusAnimation), findsOneWidget); + }); + }); + } + + testWidgets( + 'goes back to ScoreView after the animation', + (tester) async { + await tester.runAsync(() async { + final state = initialState.copyWith( + bonusHistory: [GameBonus.dashNest], + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: initialState, + ); + + await _pumpAppWithWidget(tester); + await tester.pump(); + // TODO(arturplaczek): remove magic number once this is merged: + // https://github.com/flame-engine/flame/pull/1564 + await Future.delayed(const Duration(seconds: 4)); + + await expectLater(find.byType(ScoreView), findsOneWidget); + }); + }, + ); + }); +} diff --git a/test/game/view/widgets/round_count_display_test.dart b/test/game/view/widgets/round_count_display_test.dart new file mode 100644 index 00000000..8281ce83 --- /dev/null +++ b/test/game/view/widgets/round_count_display_test.dart @@ -0,0 +1,132 @@ +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'; +import 'package:pinball/theme/app_colors.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + group('RoundCountDisplay renders', () { + late GameBloc gameBloc; + const initialState = GameState( + score: 0, + balls: 3, + bonusHistory: [], + ); + + setUp(() { + gameBloc = MockGameBloc(); + + whenListen( + gameBloc, + Stream.value(initialState), + initialState: initialState, + ); + }); + + testWidgets('three active round indicator', (tester) async { + await tester.pumpApp( + const RoundCountDisplay(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect(find.byType(RoundIndicator), findsNWidgets(3)); + }); + + testWidgets('two active round indicator', (tester) async { + final state = initialState.copyWith( + balls: 2, + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + + await tester.pumpApp( + const RoundCountDisplay(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && widget.isActive, + ), + findsNWidgets(2), + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && !widget.isActive, + ), + findsOneWidget, + ); + }); + + testWidgets('one active round indicator', (tester) async { + final state = initialState.copyWith( + balls: 1, + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + + await tester.pumpApp( + const RoundCountDisplay(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && widget.isActive, + ), + findsOneWidget, + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && !widget.isActive, + ), + findsNWidgets(2), + ); + }); + }); + + testWidgets('active round indicator is displaying with proper color', + (tester) async { + await tester.pumpApp( + const RoundIndicator(isActive: true), + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => widget is Container && widget.color == AppColors.orange, + ), + findsOneWidget, + ); + }); + + testWidgets('inactive round indicator is displaying with proper color', + (tester) async { + await tester.pumpApp( + const RoundIndicator(isActive: false), + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is Container && + widget.color == AppColors.orange.withAlpha(128), + ), + findsOneWidget, + ); + }); +} diff --git a/test/game/view/widgets/score_view_test.dart b/test/game/view/widgets/score_view_test.dart new file mode 100644 index 00000000..0d3af694 --- /dev/null +++ b/test/game/view/widgets/score_view_test.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +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'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + late GameBloc gameBloc; + late StreamController stateController; + const score = 123456789; + const initialState = GameState( + score: score, + balls: 1, + bonusHistory: [], + ); + + setUp(() { + gameBloc = MockGameBloc(); + stateController = StreamController()..add(initialState); + + whenListen( + gameBloc, + stateController.stream, + initialState: initialState, + ); + }); + + group('ScoreView', () { + testWidgets('renders score', (tester) async { + await tester.pumpApp( + const ScoreView(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect(find.text(score.formatScore()), findsOneWidget); + }); + + testWidgets('renders game over', (tester) async { + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + stateController.add( + initialState.copyWith( + balls: 0, + ), + ); + + await tester.pumpApp( + const ScoreView(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect(find.text(l10n.gameOver), findsOneWidget); + }); + + testWidgets('updates the score', (tester) async { + await tester.pumpApp( + const ScoreView(), + gameBloc: gameBloc, + ); + + expect(find.text(score.formatScore()), findsOneWidget); + + final newState = initialState.copyWith( + score: 987654321, + ); + + stateController.add(newState); + + await tester.pump(); + + expect(find.text(newState.score.formatScore()), findsOneWidget); + }); + }); +} diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index b75daf94..586ef3b0 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -9,6 +9,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/leaderboard/leaderboard.dart'; import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -33,6 +34,8 @@ class MockContactCallback extends Mock class MockGameBloc extends Mock implements GameBloc {} +class MockStartGameBloc extends Mock implements StartGameBloc {} + class MockGameState extends Mock implements GameState {} class MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index b744c33a..2c112426 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -15,6 +15,7 @@ import 'package:mockingjay/mockingjay.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'helpers.dart'; @@ -51,6 +52,7 @@ extension PumpApp on WidgetTester { Widget widget, { MockNavigator? navigator, GameBloc? gameBloc, + StartGameBloc? startGameBloc, AssetsManagerCubit? assetsManagerCubit, CharacterThemeCubit? characterThemeCubit, LeaderboardRepository? leaderboardRepository, @@ -75,6 +77,9 @@ extension PumpApp on WidgetTester { BlocProvider.value( value: gameBloc ?? MockGameBloc(), ), + BlocProvider.value( + value: startGameBloc ?? MockStartGameBloc(), + ), BlocProvider.value( value: assetsManagerCubit ?? _buildDefaultAssetsManagerCubit(), ), From 40de225140b807fbec66a3f93379c8824f102419 Mon Sep 17 00:00:00 2001 From: Rui Miguel Alonso Date: Thu, 28 Apr 2022 08:53:27 +0200 Subject: [PATCH 2/2] Update packages/pinball_components/sandbox/lib/stories/multipliers/multipliers_game.dart Co-authored-by: Alejandro Santiago --- .../sandbox/lib/stories/multipliers/multipliers_game.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/pinball_components/sandbox/lib/stories/multipliers/multipliers_game.dart b/packages/pinball_components/sandbox/lib/stories/multipliers/multipliers_game.dart index 14bea451..860663dc 100644 --- a/packages/pinball_components/sandbox/lib/stories/multipliers/multipliers_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/multipliers/multipliers_game.dart @@ -39,26 +39,23 @@ class MultipliersGame extends BallGame with KeyboardEvents { await super.onLoad(); camera.followVector2(Vector2.zero()); + x2 = Multiplier( value: 2, position: Vector2(-20, 0), ); - x3 = Multiplier( value: 3, position: Vector2(20, -5), ); - x4 = Multiplier( value: 4, position: Vector2(0, -15), ); - x5 = Multiplier( value: 5, position: Vector2(-10, -25), ); - x6 = Multiplier( value: 6, position: Vector2(10, -35),