From 26cc0861058d39f01e0e3f2df59eb2384e330170 Mon Sep 17 00:00:00 2001 From: arturplaczek Date: Fri, 22 Apr 2022 11:38:52 +0200 Subject: [PATCH] feat: update GameHud --- lib/game/view/widgets/game_hud.dart | 127 +++++++++++++++----- test/game/view/game_hud_test.dart | 83 ------------- test/game/view/widgets/game_hud_test.dart | 136 ++++++++++++++++++++++ 3 files changed, 234 insertions(+), 112 deletions(-) delete mode 100644 test/game/view/game_hud_test.dart create mode 100644 test/game/view/widgets/game_hud_test.dart diff --git a/lib/game/view/widgets/game_hud.dart b/lib/game/view/widgets/game_hud.dart index 00eedd2b..4ead431a 100644 --- a/lib/game/view/widgets/game_hud.dart +++ b/lib/game/view/widgets/game_hud.dart @@ -1,46 +1,115 @@ +// ignore_for_file: public_member_api_docs + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/gen/gen.dart'; /// {@template game_hud} -/// Overlay of a [PinballGame] that displays the current [GameState.score] and -/// [GameState.balls]. +/// Overlay of a [PinballGame] that displays the current [GameState.score], +/// [GameState.balls] and animation when the player get the bonus. /// {@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: _listenWhen, + 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, - ), - ), - ], + ), + ), + ); + } + + bool _listenWhen(GameState previous, GameState current) { + final previousCount = previous.bonusHistory.length; + final currentCount = current.bonusHistory.length; + return previousCount != currentCount; + } +} + +class _ScoreViewDecoration extends StatelessWidget { + const _ScoreViewDecoration({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + Assets.images.score.miniScoreBackground.path, + ), + fit: BoxFit.cover, ), - ], + ), + 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.dino: + return BonusAnimation.dino(onCompleted: onComplete); + case GameBonus.googleWord: + return BonusAnimation.google(onCompleted: onComplete); + case GameBonus.android: + return BonusAnimation.android(onCompleted: onComplete); + } + } +} 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/widgets/game_hud_test.dart b/test/game/view/widgets/game_hud_test.dart new file mode 100644 index 00000000..05312e60 --- /dev/null +++ b/test/game/view/widgets/game_hud_test.dart @@ -0,0 +1,136 @@ +// 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 '../../../helpers/helpers.dart'; + +void main() { + group('GameHud', () { + late GameBloc gameBloc; + late StreamController stateController; + + const initialState = GameState( + score: 10, + balls: 2, + bonusHistory: [], + ); + + setUp(() async { + gameBloc = MockGameBloc(); + stateController = StreamController()..add(initialState); + await BonusAnimation.loadAssets(); + + whenListen( + gameBloc, + stateController.stream, + 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.toString()), findsOneWidget); + }, + ); + + testWidgets( + 'on game over', + (tester) async { + final state = initialState.copyWith( + bonusHistory: [GameBonus.dashNest], + ); + + stateController.add(state); + + await tester.pumpApp( + GameHud(), + gameBloc: gameBloc, + ); + + expect(find.byType(ScoreView), findsOneWidget); + }, + ); + }); + + 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( + 'is going 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(); + + await Future.delayed(const Duration(seconds: 4)); + + await expectLater(find.byType(ScoreView), findsOneWidget); + }); + }, + ); + }); +}