feat: score widget with animation (#206)

pull/243/head
arturplaczek 3 years ago committed by GitHub
parent bd9d219f0b
commit b9c2f3a54f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -12,6 +12,12 @@ enum GameBonus {
/// Bonus achieved when a ball enters Sparky's computer. /// Bonus achieved when a ball enters Sparky's computer.
sparkyTurboCharge, sparkyTurboCharge,
/// Bonus achieved when the ball goes in the dino mouth.
dinoChomp,
/// Bonus achieved when a ball enters the android spaceship.
androidSpaceship,
} }
/// {@template game_state} /// {@template game_state}

@ -43,6 +43,7 @@ class PinballGamePage extends StatelessWidget {
final loadables = [ final loadables = [
...game.preLoadAssets(), ...game.preLoadAssets(),
pinballAudio.load(), pinballAudio.load(),
...BonusAnimation.loadAssets(),
]; ];
return MultiBlocProvider( return MultiBlocProvider(
@ -113,6 +114,13 @@ class PinballGameLoadedView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isPlaying = context.select(
(StartGameBloc bloc) => 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( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
@ -131,10 +139,13 @@ class PinballGameLoadedView extends StatelessWidget {
}, },
), ),
), ),
const Positioned( Positioned(
top: 8, top: 16,
left: 8, left: leftMargin,
child: GameHud(), child: Visibility(
visible: isPlaying,
child: const GameHud(),
),
), ),
], ],
); );

@ -1,19 +1,23 @@
// ignore_for_file: public_member_api_docs
import 'package:flame/flame.dart'; import 'package:flame/flame.dart';
import 'package:flame/sprite.dart'; import 'package:flame/sprite.dart';
import 'package:flame/widgets.dart';
import 'package:flutter/material.dart' hide Image; import 'package:flutter/material.dart' hide Image;
import 'package:pinball/gen/assets.gen.dart'; 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._( const BonusAnimation._(
this.imagePath, { String imagePath, {
VoidCallback? onCompleted, VoidCallback? onCompleted,
Key? key, Key? key,
}) : _onCompleted = onCompleted, }) : _imagePath = imagePath,
_onCompleted = onCompleted,
super(key: key); super(key: key);
/// [Widget] that displays the dash nest animation.
BonusAnimation.dashNest({ BonusAnimation.dashNest({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
@ -23,6 +27,7 @@ class BonusAnimation extends StatelessWidget {
key: key, key: key,
); );
/// [Widget] that displays the sparky turbo charge animation.
BonusAnimation.sparkyTurboCharge({ BonusAnimation.sparkyTurboCharge({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
@ -32,56 +37,94 @@ class BonusAnimation extends StatelessWidget {
key: key, key: key,
); );
BonusAnimation.dino({ /// [Widget] that displays the dino chomp animation.
BonusAnimation.dinoChomp({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
}) : this._( }) : this._(
Assets.images.bonusAnimation.dino.keyName, Assets.images.bonusAnimation.dinoChomp.keyName,
onCompleted: onCompleted, onCompleted: onCompleted,
key: key, key: key,
); );
BonusAnimation.android({ /// [Widget] that displays the android spaceship animation.
BonusAnimation.androidSpaceship({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
}) : this._( }) : this._(
Assets.images.bonusAnimation.android.keyName, Assets.images.bonusAnimation.androidSpaceship.keyName,
onCompleted: onCompleted, onCompleted: onCompleted,
key: key, key: key,
); );
BonusAnimation.google({ /// [Widget] that displays the google word animation.
BonusAnimation.googleWord({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
}) : this._( }) : this._(
Assets.images.bonusAnimation.google.keyName, Assets.images.bonusAnimation.googleWord.keyName,
onCompleted: onCompleted, onCompleted: onCompleted,
key: key, key: key,
); );
final String imagePath; final String _imagePath;
final VoidCallback? _onCompleted; final VoidCallback? _onCompleted;
static Future<void> loadAssets() { /// Returns a list of assets to be loaded for animations.
static List<Future> loadAssets() {
Flame.images.prefix = ''; Flame.images.prefix = '';
return Flame.images.loadAll([ return [
Assets.images.bonusAnimation.dashNest.keyName, Flame.images.load(Assets.images.bonusAnimation.dashNest.keyName),
Assets.images.bonusAnimation.sparkyTurboCharge.keyName, Flame.images.load(Assets.images.bonusAnimation.sparkyTurboCharge.keyName),
Assets.images.bonusAnimation.dino.keyName, Flame.images.load(Assets.images.bonusAnimation.dinoChomp.keyName),
Assets.images.bonusAnimation.android.keyName, Flame.images.load(Assets.images.bonusAnimation.androidSpaceship.keyName),
Assets.images.bonusAnimation.google.keyName, Flame.images.load(Assets.images.bonusAnimation.googleWord.keyName),
]); ];
}
@override
State<BonusAnimation> createState() => _BonusAnimationState();
}
class _BonusAnimationState extends State<BonusAnimation>
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<void>.delayed(
Duration(seconds: animation.totalDuration().ceil()),
() {
widget._onCompleted?.call();
},
);
super.didUpdateWidget(oldWidget);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spriteSheet = SpriteSheet.fromColumnsAndRows( final spriteSheet = SpriteSheet.fromColumnsAndRows(
image: Flame.images.fromCache(imagePath), image: Flame.images.fromCache(widget._imagePath),
columns: 8, columns: 8,
rows: 9, rows: 9,
); );
final animation = spriteSheet.createAnimation( animation = spriteSheet.createAnimation(
row: 0, row: 0,
stepTime: 1 / 24, stepTime: 1 / 24,
to: spriteSheet.rows * spriteSheet.columns, to: spriteSheet.rows * spriteSheet.columns,
@ -91,15 +134,22 @@ class BonusAnimation extends StatelessWidget {
Future<void>.delayed( Future<void>.delayed(
Duration(seconds: animation.totalDuration().ceil()), Duration(seconds: animation.totalDuration().ceil()),
() { () {
_onCompleted?.call(); if (shouldRunBuildCallback) {
widget._onCompleted?.call();
}
}, },
); );
controller = SpriteAnimationController(
animation: animation,
vsync: this,
)..forward();
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
child: SpriteAnimationWidget( child: SpriteAnimationWidget(
animation: animation, controller: controller,
), ),
); );
} }

@ -1,46 +1,122 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/gen.dart';
import 'package:pinball/theme/app_colors.dart';
/// {@template game_hud} /// {@template game_hud}
/// Overlay of a [PinballGame] that displays the current [GameState.score] and /// Overlay on the [PinballGame].
/// [GameState.balls]. ///
/// Displays the current [GameState.score], [GameState.balls] and animates when
/// the player gets a [GameBonus].
/// {@endtemplate} /// {@endtemplate}
class GameHud extends StatelessWidget { class GameHud extends StatefulWidget {
/// {@macro game_hud} /// {@macro game_hud}
const GameHud({Key? key}) : super(key: key); const GameHud({Key? key}) : super(key: key);
@override
State<GameHud> createState() => _GameHudState();
}
class _GameHudState extends State<GameHud> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = context.watch<GameBloc>().state; final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver);
return Container( return _ScoreViewDecoration(
color: Colors.redAccent, child: SizedBox(
width: 200, height: _width / _ratio,
height: 100, width: _width,
padding: const EdgeInsets.all(16), child: BlocListener<GameBloc, GameState>(
child: Row( listenWhen: (previous, current) =>
mainAxisAlignment: MainAxisAlignment.spaceBetween, previous.bonusHistory.length != current.bonusHistory.length,
children: [ listener: (_, __) => setState(() => showAnimation = true),
Text( child: AnimatedSwitcher(
'${state.score}', duration: kThemeAnimationDuration,
style: Theme.of(context).textTheme.headline3, 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( class _ScoreViewDecoration extends StatelessWidget {
radius: 8, const _ScoreViewDecoration({
backgroundColor: Colors.black, 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);
}
} }
} }

@ -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,
),
);
}
}

@ -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,
),
);
}
}

@ -1,3 +1,5 @@
export 'bonus_animation.dart'; export 'bonus_animation.dart';
export 'game_hud.dart'; export 'game_hud.dart';
export 'play_button_overlay.dart'; export 'play_button_overlay.dart';
export 'round_count_display.dart';
export 'score_view.dart';

@ -14,26 +14,27 @@ class $AssetsImagesGen {
const $AssetsImagesBonusAnimationGen(); const $AssetsImagesBonusAnimationGen();
$AssetsImagesComponentsGen get components => $AssetsImagesComponentsGen get components =>
const $AssetsImagesComponentsGen(); const $AssetsImagesComponentsGen();
$AssetsImagesScoreGen get score => const $AssetsImagesScoreGen();
} }
class $AssetsImagesBonusAnimationGen { class $AssetsImagesBonusAnimationGen {
const $AssetsImagesBonusAnimationGen(); const $AssetsImagesBonusAnimationGen();
/// File path: assets/images/bonus_animation/android.png /// File path: assets/images/bonus_animation/android_spaceship.png
AssetGenImage get android => AssetGenImage get androidSpaceship => const AssetGenImage(
const AssetGenImage('assets/images/bonus_animation/android.png'); 'assets/images/bonus_animation/android_spaceship.png');
/// File path: assets/images/bonus_animation/dash_nest.png /// File path: assets/images/bonus_animation/dash_nest.png
AssetGenImage get dashNest => AssetGenImage get dashNest =>
const AssetGenImage('assets/images/bonus_animation/dash_nest.png'); const AssetGenImage('assets/images/bonus_animation/dash_nest.png');
/// File path: assets/images/bonus_animation/dino.png /// File path: assets/images/bonus_animation/dino_chomp.png
AssetGenImage get dino => AssetGenImage get dinoChomp =>
const AssetGenImage('assets/images/bonus_animation/dino.png'); const AssetGenImage('assets/images/bonus_animation/dino_chomp.png');
/// File path: assets/images/bonus_animation/google.png /// File path: assets/images/bonus_animation/google_word.png
AssetGenImage get google => AssetGenImage get googleWord =>
const AssetGenImage('assets/images/bonus_animation/google.png'); const AssetGenImage('assets/images/bonus_animation/google_word.png');
/// File path: assets/images/bonus_animation/sparky_turbo_charge.png /// File path: assets/images/bonus_animation/sparky_turbo_charge.png
AssetGenImage get sparkyTurboCharge => const AssetGenImage( AssetGenImage get sparkyTurboCharge => const AssetGenImage(
@ -48,6 +49,14 @@ class $AssetsImagesComponentsGen {
const AssetGenImage('assets/images/components/background.png'); 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 { class Assets {
Assets._(); Assets._();

@ -75,5 +75,9 @@
"enterInitials": "Enter your initials", "enterInitials": "Enter your initials",
"@enterInitials": { "@enterInitials": {
"description": "Text displayed on the ending dialog when game finishes to ask the user for his initials" "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"
} }
} }

@ -5,7 +5,7 @@ import 'package:pinball/theme/theme.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
const _fontPackage = 'pinball_components'; const _fontPackage = 'pinball_components';
const _primaryFontFamily = PinballFonts.pixeloidSans; const _primaryFontFamily = FontFamily.pixeloidSans;
abstract class AppTextStyle { abstract class AppTextStyle {
static const headline1 = TextStyle( static const headline1 = TextStyle(

@ -1,2 +1,3 @@
export 'assets.gen.dart'; export 'assets.gen.dart';
export 'fonts.gen.dart';
export 'pinball_fonts.dart'; export 'pinball_fonts.dart';

@ -48,6 +48,7 @@ flutter:
assets: assets:
- assets/images/components/ - assets/images/components/
- assets/images/bonus_animation/ - assets/images/bonus_animation/
- assets/images/score/
flutter_gen: flutter_gen:
line_length: 80 line_length: 80

@ -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<void> _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),
);
});
});
}

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball/start_game/start_game.dart';
import '../../helpers/helpers.dart'; import '../../helpers/helpers.dart';
@ -79,6 +80,7 @@ void main() {
'renders PinballGameLoadedView after resources have been loaded', 'renders PinballGameLoadedView after resources have been loaded',
(tester) async { (tester) async {
final assetsManagerCubit = MockAssetsManagerCubit(); final assetsManagerCubit = MockAssetsManagerCubit();
final startGameBloc = MockStartGameBloc();
final loadedAssetsState = AssetsManagerState( final loadedAssetsState = AssetsManagerState(
loadables: [Future<void>.value()], loadables: [Future<void>.value()],
@ -89,6 +91,11 @@ void main() {
Stream.value(loadedAssetsState), Stream.value(loadedAssetsState),
initialState: loadedAssetsState, initialState: loadedAssetsState,
); );
whenListen(
startGameBloc,
Stream.value(StartGameState.initial()),
initialState: StartGameState.initial(),
);
await tester.pumpApp( await tester.pumpApp(
PinballGameView( PinballGameView(
@ -97,6 +104,7 @@ void main() {
assetsManagerCubit: assetsManagerCubit, assetsManagerCubit: assetsManagerCubit,
characterThemeCubit: characterThemeCubit, characterThemeCubit: characterThemeCubit,
gameBloc: gameBloc, gameBloc: gameBloc,
startGameBloc: startGameBloc,
); );
await tester.pump(); await tester.pump();
@ -160,27 +168,59 @@ void main() {
}); });
group('PinballGameView', () { group('PinballGameView', () {
final gameBloc = MockGameBloc();
final startGameBloc = MockStartGameBloc();
setUp(() async { setUp(() async {
await Future.wait<void>(game.preLoadAssets()); await Future.wait<void>(game.preLoadAssets());
});
testWidgets('renders game and a hud', (tester) async {
final gameBloc = MockGameBloc();
whenListen( whenListen(
gameBloc, gameBloc,
Stream.value(const GameState.initial()), Stream.value(const GameState.initial()),
initialState: const GameState.initial(), initialState: const GameState.initial(),
); );
whenListen(
startGameBloc,
Stream.value(StartGameState.initial()),
initialState: StartGameState.initial(),
);
});
testWidgets('renders game', (tester) async {
await tester.pumpApp( await tester.pumpApp(
PinballGameView(game: game), PinballGameView(game: game),
gameBloc: gameBloc, gameBloc: gameBloc,
startGameBloc: startGameBloc,
); );
expect( expect(
find.byWidgetPredicate((w) => w is GameWidget<PinballGame>), find.byWidgetPredicate((w) => w is GameWidget<PinballGame>),
findsOneWidget, 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( expect(
find.byType(GameHud), find.byType(GameHud),
findsOneWidget, findsOneWidget,

@ -1,13 +1,13 @@
import 'dart:async'; // ignore_for_file: invalid_use_of_protected_member
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flame/assets.dart'; import 'package:flame/assets.dart';
import 'package:flame/widgets.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/view/widgets/bonus_animation.dart'; import 'package:pinball/game/view/widgets/bonus_animation.dart';
import 'package:pinball_flame/pinball_flame.dart';
import '../../../helpers/helpers.dart'; import '../../../helpers/helpers.dart';
@ -15,11 +15,15 @@ class MockImages extends Mock implements Images {}
class MockImage extends Mock implements ui.Image {} class MockImage extends Mock implements ui.Image {}
class MockCallback extends Mock {
void call();
}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async { setUp(() async {
await BonusAnimation.loadAssets(); await Future.wait<void>(BonusAnimation.loadAssets());
}); });
group('loads SpriteAnimationWidget correctly for', () { group('loads SpriteAnimationWidget correctly for', () {
@ -32,9 +36,9 @@ void main() {
expect(find.byType(SpriteAnimationWidget), findsOneWidget); expect(find.byType(SpriteAnimationWidget), findsOneWidget);
}); });
testWidgets('dino', (tester) async { testWidgets('dinoChomp', (tester) async {
await tester.pumpApp( await tester.pumpApp(
BonusAnimation.dino(), BonusAnimation.dinoChomp(),
); );
await tester.pump(); await tester.pump();
@ -50,18 +54,18 @@ void main() {
expect(find.byType(SpriteAnimationWidget), findsOneWidget); expect(find.byType(SpriteAnimationWidget), findsOneWidget);
}); });
testWidgets('google', (tester) async { testWidgets('googleWord', (tester) async {
await tester.pumpApp( await tester.pumpApp(
BonusAnimation.google(), BonusAnimation.googleWord(),
); );
await tester.pump(); await tester.pump();
expect(find.byType(SpriteAnimationWidget), findsOneWidget); expect(find.byType(SpriteAnimationWidget), findsOneWidget);
}); });
testWidgets('android', (tester) async { testWidgets('androidSpaceship', (tester) async {
await tester.pumpApp( await tester.pumpApp(
BonusAnimation.android(), BonusAnimation.androidSpaceship(),
); );
await tester.pump(); await tester.pump();
@ -74,14 +78,14 @@ void main() {
// https://github.com/flame-engine/flame/issues/1543 // https://github.com/flame-engine/flame/issues/1543
testWidgets('called onCompleted callback at the end of animation ', testWidgets('called onCompleted callback at the end of animation ',
(tester) async { (tester) async {
final completer = Completer<void>(); final callback = MockCallback();
await tester.runAsync(() async { await tester.runAsync(() async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: BonusAnimation.dashNest( body: BonusAnimation.dashNest(
onCompleted: completer.complete, onCompleted: callback.call,
), ),
), ),
), ),
@ -93,7 +97,63 @@ void main() {
await tester.pump(); 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<void>.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<void>.delayed(const Duration(seconds: 4));
await tester.pump();
verify(callback.call).called(1);
}); });
}); });
} }

@ -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<void>(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<void> _pumpAppWithWidget(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: BlocProvider<GameBloc>.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<void>.delayed(const Duration(seconds: 4));
await expectLater(find.byType(ScoreView), findsOneWidget);
});
},
);
});
}

@ -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,
);
});
}

@ -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<GameState> stateController;
const score = 123456789;
const initialState = GameState(
score: score,
balls: 1,
bonusHistory: [],
);
setUp(() {
gameBloc = MockGameBloc();
stateController = StreamController<GameState>()..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);
});
});
}

@ -9,6 +9,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/leaderboard/leaderboard.dart'; import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball/select_character/select_character.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_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
@ -33,6 +34,8 @@ class MockContactCallback extends Mock
class MockGameBloc extends Mock implements GameBloc {} class MockGameBloc extends Mock implements GameBloc {}
class MockStartGameBloc extends Mock implements StartGameBloc {}
class MockGameState extends Mock implements GameState {} class MockGameState extends Mock implements GameState {}
class MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {} class MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {}

@ -15,6 +15,7 @@ import 'package:mockingjay/mockingjay.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/select_character/select_character.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_audio/pinball_audio.dart';
import 'helpers.dart'; import 'helpers.dart';
@ -51,6 +52,7 @@ extension PumpApp on WidgetTester {
Widget widget, { Widget widget, {
MockNavigator? navigator, MockNavigator? navigator,
GameBloc? gameBloc, GameBloc? gameBloc,
StartGameBloc? startGameBloc,
AssetsManagerCubit? assetsManagerCubit, AssetsManagerCubit? assetsManagerCubit,
CharacterThemeCubit? characterThemeCubit, CharacterThemeCubit? characterThemeCubit,
LeaderboardRepository? leaderboardRepository, LeaderboardRepository? leaderboardRepository,
@ -75,6 +77,9 @@ extension PumpApp on WidgetTester {
BlocProvider.value( BlocProvider.value(
value: gameBloc ?? MockGameBloc(), value: gameBloc ?? MockGameBloc(),
), ),
BlocProvider.value(
value: startGameBloc ?? MockStartGameBloc(),
),
BlocProvider.value( BlocProvider.value(
value: assetsManagerCubit ?? _buildDefaultAssetsManagerCubit(), value: assetsManagerCubit ?? _buildDefaultAssetsManagerCubit(),
), ),

Loading…
Cancel
Save