feat: score widget with animation (#206)

pull/243/head
arturplaczek 2 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.
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}

@ -43,6 +43,7 @@ class PinballGamePage extends StatelessWidget {
final loadables = [
...game.preLoadAssets(),
pinballAudio.load(),
...BonusAnimation.loadAssets(),
];
return MultiBlocProvider(
@ -113,6 +114,13 @@ class PinballGameLoadedView extends StatelessWidget {
@override
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(
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(),
),
),
],
);

@ -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<void> loadAssets() {
/// Returns a list of assets to be loaded for animations.
static List<Future> 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<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
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<void>.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,
),
);
}

@ -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<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
Widget build(BuildContext context) {
final state = context.watch<GameBloc>().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<GameBloc, GameState>(
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);
}
}
}

@ -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 'game_hud.dart';
export 'play_button_overlay.dart';
export 'round_count_display.dart';
export 'score_view.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._();

@ -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"
}
}

@ -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(

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

@ -48,6 +48,7 @@ flutter:
assets:
- assets/images/components/
- assets/images/bonus_animation/
- assets/images/score/
flutter_gen:
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: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<void>.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<void>(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<PinballGame>),
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,

@ -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<void>(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<void>();
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<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/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 {}

@ -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(),
),

Loading…
Cancel
Save