diff --git a/.github/workflows/pinball_audio.yaml b/.github/workflows/pinball_audio.yaml new file mode 100644 index 00000000..7a43413a --- /dev/null +++ b/.github/workflows/pinball_audio.yaml @@ -0,0 +1,19 @@ +name: pinball_audio + +on: + push: + paths: + - "packages/pinball_audio/**" + - ".github/workflows/pinball_audio.yaml" + + pull_request: + paths: + - "packages/pinball_audio/**" + - ".github/workflows/pinball_audio.yaml" + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + working_directory: packages/pinball_audio + coverage_excludes: "lib/gen/*.dart" diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 8de80730..521d575e 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -13,18 +13,27 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/landing/landing.dart'; +import 'package:pinball_audio/pinball_audio.dart'; class App extends StatelessWidget { - const App({Key? key, required LeaderboardRepository leaderboardRepository}) - : _leaderboardRepository = leaderboardRepository, + const App({ + Key? key, + required LeaderboardRepository leaderboardRepository, + required PinballAudio pinballAudio, + }) : _leaderboardRepository = leaderboardRepository, + _pinballAudio = pinballAudio, super(key: key); final LeaderboardRepository _leaderboardRepository; + final PinballAudio _pinballAudio; @override Widget build(BuildContext context) { - return RepositoryProvider.value( - value: _leaderboardRepository, + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: _leaderboardRepository), + RepositoryProvider.value(value: _pinballAudio), + ], child: MaterialApp( title: 'I/O Pinball', theme: ThemeData( diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index e71d5ede..a312daee 100644 --- a/lib/game/components/board.dart +++ b/lib/game/components/board.dart @@ -8,7 +8,7 @@ import 'package:pinball_components/pinball_components.dart'; class Board extends Component { /// {@macro board} // TODO(alestiago): Make Board a Blueprint and sort out priorities. - Board() : super(priority: 5); + Board() : super(priority: 1); @override Future onLoad() async { @@ -83,8 +83,8 @@ class _BottomGroupSide extends Component { final kicker = Kicker( side: _side, )..initialPosition = Vector2( - (22.0 * direction) + centerXAdjustment, - -26, + (22.4 * direction) + centerXAdjustment, + -25, ); await addAll([flipper, baseboard, kicker]); diff --git a/lib/game/components/bonus_word.dart b/lib/game/components/bonus_word.dart index e7f1626a..3457e84c 100644 --- a/lib/game/components/bonus_word.dart +++ b/lib/game/components/bonus_word.dart @@ -13,7 +13,8 @@ import 'package:pinball_components/pinball_components.dart'; /// {@template bonus_word} /// Loads all [BonusLetter]s to compose a [BonusWord]. /// {@endtemplate} -class BonusWord extends Component with BlocComponent { +class BonusWord extends Component + with BlocComponent, HasGameRef { /// {@macro bonus_word} BonusWord({required Vector2 position}) : _position = position; @@ -29,6 +30,8 @@ class BonusWord extends Component with BlocComponent { @override void onNewState(GameState state) { if (state.bonusHistory.last == GameBonus.word) { + gameRef.audio.googleBonus(); + final letters = children.whereType().toList(); for (var i = 0; i < letters.length; i++) { diff --git a/lib/game/components/score_points.dart b/lib/game/components/score_points.dart index 39910777..ce13c718 100644 --- a/lib/game/components/score_points.dart +++ b/lib/game/components/score_points.dart @@ -37,5 +37,7 @@ class BallScorePointsCallback extends ContactCallback { _gameRef.read().add( Scored(points: scorePoints.points), ); + + _gameRef.audio.score(); } } diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index 6bafe37e..47175c32 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -13,6 +13,8 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.flipper.right.keyName), images.load(components.Assets.images.baseboard.left.keyName), images.load(components.Assets.images.baseboard.right.keyName), + images.load(components.Assets.images.kicker.left.keyName), + images.load(components.Assets.images.kicker.right.keyName), images.load(components.Assets.images.launchRamp.ramp.keyName), images.load( components.Assets.images.launchRamp.foregroundRailing.keyName, diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 10980142..3a04b341 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -7,18 +7,19 @@ import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/gen/assets.gen.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart' hide Assets; class PinballGame extends Forge2DGame with FlameBloc, HasKeyboardHandlerComponents { - PinballGame({required this.theme}) { + PinballGame({required this.theme, required this.audio}) { images.prefix = ''; } final PinballTheme theme; - late final Plunger plunger; + final PinballAudio audio; @override void onAttach() { @@ -31,11 +32,13 @@ class PinballGame extends Forge2DGame _addContactCallbacks(); await _addGameBoundaries(); - unawaited(add(Board())); unawaited(addFromBlueprint(Boundaries())); + unawaited(addFromBlueprint(LaunchRamp())); unawaited(_addPlunger()); + unawaited(add(Board())); + unawaited(addFromBlueprint(DinoWalls())); unawaited(_addBonusWord()); - unawaited(_addRamps()); + unawaited(addFromBlueprint(SpaceshipRamp())); unawaited( addFromBlueprint( Spaceship( @@ -64,17 +67,10 @@ class PinballGame extends Forge2DGame Future _addGameBoundaries() async { await add(BottomWall()); createBoundaries(this).forEach(add); - unawaited( - addFromBlueprint( - DinoWalls( - position: Vector2(-2.4, 0), - ), - ), - ); } Future _addPlunger() async { - plunger = Plunger(compressionDistance: 29) + final plunger = Plunger(compressionDistance: 29) ..initialPosition = Vector2(38, -19); await add(plunger); } @@ -90,24 +86,31 @@ class PinballGame extends Forge2DGame ); } - Future _addRamps() async { - unawaited(addFromBlueprint(SpaceshipRamp())); - unawaited(addFromBlueprint(LaunchRamp())); - } + Future spawnBall() async { + // TODO(alestiago): Remove once this logic is moved to controller. + var plunger = firstChild(); + if (plunger == null) { + await add(plunger = Plunger(compressionDistance: 1)); + } - void spawnBall() { final ball = ControlledBall.launch( theme: theme, )..initialPosition = Vector2( plunger.body.position.x, plunger.body.position.y + Ball.size.y, ); - add(ball); + await add(ball); } } class DebugPinballGame extends PinballGame with TapDetector { - DebugPinballGame({required PinballTheme theme}) : super(theme: theme); + DebugPinballGame({ + required PinballTheme theme, + required PinballAudio audio, + }) : super( + theme: theme, + audio: audio, + ); @override Future onLoad() async { diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 0fa6a1ad..e50eb2d7 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_theme/pinball_theme.dart'; class PinballGamePage extends StatelessWidget { @@ -51,13 +52,24 @@ class _PinballGameViewState extends State { void initState() { super.initState(); + final audio = context.read(); + + _game = widget._isDebugMode + ? DebugPinballGame(theme: widget.theme, audio: audio) + : PinballGame(theme: widget.theme, audio: audio); + // TODO(erickzanardo): Revisit this when we start to have more assets // this could expose a Stream (maybe even a cubit?) so we could show the // the loading progress with some fancy widgets. - _game = (widget._isDebugMode - ? DebugPinballGame(theme: widget.theme) - : PinballGame(theme: widget.theme)) - ..preLoadAssets(); + _fetchAssets(); + } + + Future _fetchAssets() async { + final pinballAudio = context.read(); + await Future.wait([ + _game.preLoadAssets(), + pinballAudio.load(), + ]); } @override diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 90013646..5c2a87c2 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -3,8 +3,6 @@ /// FlutterGen /// ***************************************************** -// ignore_for_file: directives_ordering,unnecessary_import - import 'package:flutter/widgets.dart'; class $AssetsImagesGen { @@ -17,7 +15,6 @@ class $AssetsImagesGen { class $AssetsImagesComponentsGen { const $AssetsImagesComponentsGen(); - /// File path: assets/images/components/background.png AssetGenImage get background => const AssetGenImage('assets/images/components/background.png'); diff --git a/lib/main_development.dart b/lib/main_development.dart index 8673eff4..8944073d 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -8,10 +8,15 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/bootstrap.dart'; +import 'package:pinball_audio/pinball_audio.dart'; void main() { bootstrap((firestore) async { final leaderboardRepository = LeaderboardRepository(firestore); - return App(leaderboardRepository: leaderboardRepository); + final pinballAudio = PinballAudio(); + return App( + leaderboardRepository: leaderboardRepository, + pinballAudio: pinballAudio, + ); }); } diff --git a/lib/main_production.dart b/lib/main_production.dart index 8673eff4..8944073d 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -8,10 +8,15 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/bootstrap.dart'; +import 'package:pinball_audio/pinball_audio.dart'; void main() { bootstrap((firestore) async { final leaderboardRepository = LeaderboardRepository(firestore); - return App(leaderboardRepository: leaderboardRepository); + final pinballAudio = PinballAudio(); + return App( + leaderboardRepository: leaderboardRepository, + pinballAudio: pinballAudio, + ); }); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 8673eff4..8944073d 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -8,10 +8,15 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/bootstrap.dart'; +import 'package:pinball_audio/pinball_audio.dart'; void main() { bootstrap((firestore) async { final leaderboardRepository = LeaderboardRepository(firestore); - return App(leaderboardRepository: leaderboardRepository); + final pinballAudio = PinballAudio(); + return App( + leaderboardRepository: leaderboardRepository, + pinballAudio: pinballAudio, + ); }); } diff --git a/packages/pinball_audio/.gitignore b/packages/pinball_audio/.gitignore new file mode 100644 index 00000000..d6130351 --- /dev/null +++ b/packages/pinball_audio/.gitignore @@ -0,0 +1,39 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/pinball_audio/README.md b/packages/pinball_audio/README.md new file mode 100644 index 00000000..f8b69df7 --- /dev/null +++ b/packages/pinball_audio/README.md @@ -0,0 +1,11 @@ +# pinball_audio + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Package with the sound manager for the pinball game + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis \ No newline at end of file diff --git a/packages/pinball_audio/analysis_options.yaml b/packages/pinball_audio/analysis_options.yaml new file mode 100644 index 00000000..f8155aa6 --- /dev/null +++ b/packages/pinball_audio/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:very_good_analysis/analysis_options.2.4.0.yaml +analyzer: + exclude: + - lib/**/*.gen.dart diff --git a/packages/pinball_audio/assets/sfx/google.ogg b/packages/pinball_audio/assets/sfx/google.ogg new file mode 100644 index 00000000..dafaa8d4 Binary files /dev/null and b/packages/pinball_audio/assets/sfx/google.ogg differ diff --git a/packages/pinball_audio/assets/sfx/plim.ogg b/packages/pinball_audio/assets/sfx/plim.ogg new file mode 100644 index 00000000..137c22b7 Binary files /dev/null and b/packages/pinball_audio/assets/sfx/plim.ogg differ diff --git a/packages/pinball_audio/lib/gen/assets.gen.dart b/packages/pinball_audio/lib/gen/assets.gen.dart new file mode 100644 index 00000000..3609b939 --- /dev/null +++ b/packages/pinball_audio/lib/gen/assets.gen.dart @@ -0,0 +1,69 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +import 'package:flutter/widgets.dart'; + +class $AssetsSfxGen { + const $AssetsSfxGen(); + + String get google => 'assets/sfx/google.ogg'; + String get plim => 'assets/sfx/plim.ogg'; +} + +class Assets { + Assets._(); + + static const $AssetsSfxGen sfx = $AssetsSfxGen(); +} + +class AssetGenImage extends AssetImage { + const AssetGenImage(String assetName) + : super(assetName, package: 'pinball_audio'); + + Image image({ + Key? key, + ImageFrameBuilder? frameBuilder, + ImageLoadingBuilder? loadingBuilder, + ImageErrorWidgetBuilder? errorBuilder, + String? semanticLabel, + bool excludeFromSemantics = false, + double? width, + double? height, + Color? color, + BlendMode? colorBlendMode, + BoxFit? fit, + AlignmentGeometry alignment = Alignment.center, + ImageRepeat repeat = ImageRepeat.noRepeat, + Rect? centerSlice, + bool matchTextDirection = false, + bool gaplessPlayback = false, + bool isAntiAlias = false, + FilterQuality filterQuality = FilterQuality.low, + }) { + return Image( + key: key, + image: this, + frameBuilder: frameBuilder, + loadingBuilder: loadingBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + width: width, + height: height, + color: color, + colorBlendMode: colorBlendMode, + fit: fit, + alignment: alignment, + repeat: repeat, + centerSlice: centerSlice, + matchTextDirection: matchTextDirection, + gaplessPlayback: gaplessPlayback, + isAntiAlias: isAntiAlias, + filterQuality: filterQuality, + ); + } + + String get path => assetName; +} diff --git a/packages/pinball_audio/lib/pinball_audio.dart b/packages/pinball_audio/lib/pinball_audio.dart new file mode 100644 index 00000000..a72ce165 --- /dev/null +++ b/packages/pinball_audio/lib/pinball_audio.dart @@ -0,0 +1,3 @@ +library pinball_audio; + +export 'src/pinball_audio.dart'; diff --git a/packages/pinball_audio/lib/src/pinball_audio.dart b/packages/pinball_audio/lib/src/pinball_audio.dart new file mode 100644 index 00000000..b2875084 --- /dev/null +++ b/packages/pinball_audio/lib/src/pinball_audio.dart @@ -0,0 +1,71 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flame_audio/audio_pool.dart'; +import 'package:flame_audio/flame_audio.dart'; +import 'package:pinball_audio/gen/assets.gen.dart'; + +/// Function that defines the contract of the creation +/// of an [AudioPool] +typedef CreateAudioPool = Future Function( + String sound, { + bool? repeating, + int? maxPlayers, + int? minPlayers, + String? prefix, +}); + +/// Function that defines the contract for playing a single +/// audio +typedef PlaySingleAudio = Future Function(String); + +/// Function that defines the contract for configuring +/// an [AudioCache] instance +typedef ConfigureAudioCache = void Function(AudioCache); + +/// {@template pinball_audio} +/// Sound manager for the pinball game +/// {@endtemplate} +class PinballAudio { + /// {@macro pinball_audio} + PinballAudio({ + CreateAudioPool? createAudioPool, + PlaySingleAudio? playSingleAudio, + ConfigureAudioCache? configureAudioCache, + }) : _createAudioPool = createAudioPool ?? AudioPool.create, + _playSingleAudio = playSingleAudio ?? FlameAudio.audioCache.play, + _configureAudioCache = configureAudioCache ?? + ((AudioCache a) { + a.prefix = ''; + }); + + final CreateAudioPool _createAudioPool; + + final PlaySingleAudio _playSingleAudio; + + final ConfigureAudioCache _configureAudioCache; + + late AudioPool _scorePool; + + /// Loads the sounds effects into the memory + Future load() async { + _configureAudioCache(FlameAudio.audioCache); + _scorePool = await _createAudioPool( + _prefixFile(Assets.sfx.plim), + maxPlayers: 4, + prefix: '', + ); + } + + /// Plays the basic score sound + void score() { + _scorePool.start(); + } + + /// Plays the google word bonus + void googleBonus() { + _playSingleAudio(_prefixFile(Assets.sfx.google)); + } + + String _prefixFile(String file) { + return 'packages/pinball_audio/$file'; + } +} diff --git a/packages/pinball_audio/pubspec.yaml b/packages/pinball_audio/pubspec.yaml new file mode 100644 index 00000000..a34ba5b5 --- /dev/null +++ b/packages/pinball_audio/pubspec.yaml @@ -0,0 +1,28 @@ +name: pinball_audio +description: Package with the sound manager for the pinball game +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.16.0 <3.0.0" + +dependencies: + audioplayers: ^0.20.1 + flame_audio: ^1.0.1 + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: ^0.3.0 + very_good_analysis: ^2.4.0 + +flutter_gen: + line_length: 80 + assets: + package_parameter_enabled: true + +flutter: + assets: + - assets/sfx/ diff --git a/packages/pinball_audio/test/helpers/helpers.dart b/packages/pinball_audio/test/helpers/helpers.dart new file mode 100644 index 00000000..efe914f6 --- /dev/null +++ b/packages/pinball_audio/test/helpers/helpers.dart @@ -0,0 +1 @@ +export 'mocks.dart'; diff --git a/packages/pinball_audio/test/helpers/mocks.dart b/packages/pinball_audio/test/helpers/mocks.dart new file mode 100644 index 00000000..c80fe65b --- /dev/null +++ b/packages/pinball_audio/test/helpers/mocks.dart @@ -0,0 +1,34 @@ +// ignore_for_file: one_member_abstracts + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flame_audio/audio_pool.dart'; +import 'package:mocktail/mocktail.dart'; + +abstract class _CreateAudioPoolStub { + Future onCall( + String sound, { + bool? repeating, + int? maxPlayers, + int? minPlayers, + String? prefix, + }); +} + +class CreateAudioPoolStub extends Mock implements _CreateAudioPoolStub {} + +abstract class _ConfigureAudioCacheStub { + void onCall(AudioCache cache); +} + +class ConfigureAudioCacheStub extends Mock implements _ConfigureAudioCacheStub { +} + +abstract class _PlaySingleAudioStub { + Future onCall(String url); +} + +class PlaySingleAudioStub extends Mock implements _PlaySingleAudioStub {} + +class MockAudioPool extends Mock implements AudioPool {} + +class MockAudioCache extends Mock implements AudioCache {} diff --git a/packages/pinball_audio/test/src/pinball_audio_test.dart b/packages/pinball_audio/test/src/pinball_audio_test.dart new file mode 100644 index 00000000..2efe9553 --- /dev/null +++ b/packages/pinball_audio/test/src/pinball_audio_test.dart @@ -0,0 +1,110 @@ +// ignore_for_file: prefer_const_constructors +import 'package:flame_audio/flame_audio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_audio/gen/assets.gen.dart'; +import 'package:pinball_audio/pinball_audio.dart'; + +import '../helpers/helpers.dart'; + +void main() { + group('PinballAudio', () { + test('can be instantiated', () { + expect(PinballAudio(), isNotNull); + }); + + late CreateAudioPoolStub createAudioPool; + late ConfigureAudioCacheStub configureAudioCache; + late PlaySingleAudioStub playSingleAudio; + late PinballAudio audio; + + setUpAll(() { + registerFallbackValue(MockAudioCache()); + }); + + setUp(() { + createAudioPool = CreateAudioPoolStub(); + when( + () => createAudioPool.onCall( + any(), + maxPlayers: any(named: 'maxPlayers'), + prefix: any(named: 'prefix'), + ), + ).thenAnswer((_) async => MockAudioPool()); + + configureAudioCache = ConfigureAudioCacheStub(); + when(() => configureAudioCache.onCall(any())).thenAnswer((_) {}); + + playSingleAudio = PlaySingleAudioStub(); + when(() => playSingleAudio.onCall(any())).thenAnswer((_) async {}); + + audio = PinballAudio( + configureAudioCache: configureAudioCache.onCall, + createAudioPool: createAudioPool.onCall, + playSingleAudio: playSingleAudio.onCall, + ); + }); + + group('load', () { + test('creates the score pool', () async { + await audio.load(); + + verify( + () => createAudioPool.onCall( + 'packages/pinball_audio/${Assets.sfx.plim}', + maxPlayers: 4, + prefix: '', + ), + ).called(1); + }); + + test('configures the audio cache instance', () async { + await audio.load(); + + verify(() => configureAudioCache.onCall(FlameAudio.audioCache)) + .called(1); + }); + + test('sets the correct prefix', () async { + audio = PinballAudio( + createAudioPool: createAudioPool.onCall, + playSingleAudio: playSingleAudio.onCall, + ); + await audio.load(); + + expect(FlameAudio.audioCache.prefix, equals('')); + }); + }); + + group('score', () { + test('plays the score sound pool', () async { + final audioPool = MockAudioPool(); + when(audioPool.start).thenAnswer((_) async => () {}); + when( + () => createAudioPool.onCall( + any(), + maxPlayers: any(named: 'maxPlayers'), + prefix: any(named: 'prefix'), + ), + ).thenAnswer((_) async => audioPool); + + await audio.load(); + audio.score(); + + verify(audioPool.start).called(1); + }); + }); + + group('googleBonus', () { + test('plays the correct file', () async { + await audio.load(); + audio.googleBonus(); + + verify( + () => playSingleAudio + .onCall('packages/pinball_audio/${Assets.sfx.google}'), + ).called(1); + }); + }); + }); +} diff --git a/packages/pinball_components/assets/images/kicker/left.png b/packages/pinball_components/assets/images/kicker/left.png new file mode 100644 index 00000000..42bd5030 Binary files /dev/null and b/packages/pinball_components/assets/images/kicker/left.png differ diff --git a/packages/pinball_components/assets/images/kicker/right.png b/packages/pinball_components/assets/images/kicker/right.png new file mode 100644 index 00000000..0a746f3c Binary files /dev/null and b/packages/pinball_components/assets/images/kicker/right.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index c928df79..154930bc 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -26,6 +26,7 @@ class $AssetsImagesGen { AssetGenImage get flutterSignPost => const AssetGenImage('assets/images/flutter_sign_post.png'); + $AssetsImagesKickerGen get kicker => const $AssetsImagesKickerGen(); $AssetsImagesLaunchRampGen get launchRamp => const $AssetsImagesLaunchRampGen(); $AssetsImagesSpaceshipGen get spaceship => const $AssetsImagesSpaceshipGen(); @@ -100,6 +101,18 @@ class $AssetsImagesFlipperGen { const AssetGenImage('assets/images/flipper/right.png'); } +class $AssetsImagesKickerGen { + const $AssetsImagesKickerGen(); + + /// File path: assets/images/kicker/left.png + AssetGenImage get left => + const AssetGenImage('assets/images/kicker/left.png'); + + /// File path: assets/images/kicker/right.png + AssetGenImage get right => + const AssetGenImage('assets/images/kicker/right.png'); +} + class $AssetsImagesLaunchRampGen { const $AssetsImagesLaunchRampGen(); diff --git a/packages/pinball_components/lib/src/components/dino_walls.dart b/packages/pinball_components/lib/src/components/dino_walls.dart index 13f56ff3..daf83850 100644 --- a/packages/pinball_components/lib/src/components/dino_walls.dart +++ b/packages/pinball_components/lib/src/components/dino_walls.dart @@ -12,16 +12,13 @@ import 'package:pinball_components/pinball_components.dart' hide Assets; /// {@endtemplate} class DinoWalls extends Forge2DBlueprint { /// {@macro dinowalls} - DinoWalls({required this.position}); - - /// The [position] where the elements will be created - final Vector2 position; + DinoWalls(); @override void build(_) { addAll([ - _DinoTopWall()..initialPosition = position, - _DinoBottomWall()..initialPosition = position, + _DinoTopWall(), + _DinoBottomWall(), ]); } } @@ -31,7 +28,7 @@ class DinoWalls extends Forge2DBlueprint { /// {@endtemplate} class _DinoTopWall extends BodyComponent with InitialPosition { ///{@macro dino_top_wall} - _DinoTopWall() : super(priority: 2); + _DinoTopWall() : super(priority: 1); List _createFixtureDefs() { final fixturesDef = []; @@ -129,7 +126,7 @@ class _DinoTopWall extends BodyComponent with InitialPosition { /// {@endtemplate} class _DinoBottomWall extends BodyComponent with InitialPosition { ///{@macro dino_top_wall} - _DinoBottomWall() : super(priority: 2); + _DinoBottomWall() : super(priority: 1); List _createFixtureDefs() { final fixturesDef = []; diff --git a/packages/pinball_components/lib/src/components/kicker.dart b/packages/pinball_components/lib/src/components/kicker.dart index d9eb7932..442f4200 100644 --- a/packages/pinball_components/lib/src/components/kicker.dart +++ b/packages/pinball_components/lib/src/components/kicker.dart @@ -1,10 +1,10 @@ import 'dart:math' as math; -import 'package:flame/extensions.dart'; +import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/material.dart'; import 'package:geometry/geometry.dart' as geometry show centroid; -import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/gen/assets.gen.dart'; +import 'package:pinball_components/pinball_components.dart' hide Assets; /// {@template kicker} /// Triangular [BodyType.static] body that propels the [Ball] towards the @@ -16,12 +16,7 @@ class Kicker extends BodyComponent with InitialPosition { /// {@macro kicker} Kicker({ required BoardSide side, - }) : _side = side { - // TODO(alestiago): Use sprite instead of color when provided. - paint = Paint() - ..color = const Color(0xFF00FF00) - ..style = PaintingStyle.fill; - } + }) : _side = side; /// Whether the [Kicker] is on the left or right side of the board. /// @@ -31,24 +26,22 @@ class Kicker extends BodyComponent with InitialPosition { final BoardSide _side; /// The size of the [Kicker] body. - // TODO(alestiago): Use size from PositionedBodyComponent instead, - // once a sprite is given. - static final Vector2 size = Vector2(4, 10); + static final Vector2 size = Vector2(4.4, 15); List _createFixtureDefs() { final fixturesDefs = []; final direction = _side.direction; const quarterPi = math.pi / 4; - final upperCircle = CircleShape()..radius = 1.45; + final upperCircle = CircleShape()..radius = 1.6; upperCircle.position.setValues(0, -upperCircle.radius / 2); final upperCircleFixtureDef = FixtureDef(upperCircle)..friction = 0; fixturesDefs.add(upperCircleFixtureDef); - final lowerCircle = CircleShape()..radius = 1.45; + final lowerCircle = CircleShape()..radius = 1.6; lowerCircle.position.setValues( size.x * -direction, - -size.y, + -size.y - 0.8, ); final lowerCircleFixtureDef = FixtureDef(lowerCircle)..friction = 0; fixturesDefs.add(lowerCircleFixtureDef); @@ -60,8 +53,7 @@ class Kicker extends BodyComponent with InitialPosition { upperCircle.radius * direction, 0, ), - // TODO(alestiago): Use values from design. - Vector2(2.0 * direction, -size.y + 2), + Vector2(2.5 * direction, -size.y + 2), ); final wallFacingLineFixtureDef = FixtureDef(wallFacingEdge)..friction = 0; fixturesDefs.add(wallFacingLineFixtureDef); @@ -125,6 +117,27 @@ class Kicker extends BodyComponent with InitialPosition { return body; } + + @override + Future onLoad() async { + await super.onLoad(); + renderBody = false; + + final sprite = await gameRef.loadSprite( + (_side.isLeft) + ? Assets.images.kicker.left.keyName + : Assets.images.kicker.right.keyName, + ); + + await add( + SpriteComponent( + sprite: sprite, + size: Vector2(8.7, 19), + anchor: Anchor.center, + position: Vector2(0.7 * -_side.direction, -2.2), + ), + ); + } } // TODO(alestiago): Evaluate if there's value on generalising this to diff --git a/packages/pinball_components/lib/src/components/launch_ramp.dart b/packages/pinball_components/lib/src/components/launch_ramp.dart index 5f7ee098..3268cc46 100644 --- a/packages/pinball_components/lib/src/components/launch_ramp.dart +++ b/packages/pinball_components/lib/src/components/launch_ramp.dart @@ -142,7 +142,7 @@ class _LaunchRampBase extends BodyComponent with InitialPosition, Layered { class _LaunchRampForegroundRailing extends BodyComponent with InitialPosition, Layered { /// {@macro launch_ramp_foreground_railing} - _LaunchRampForegroundRailing() : super(priority: 4) { + _LaunchRampForegroundRailing() : super(priority: 1) { layer = Layer.launcher; } @@ -207,7 +207,6 @@ class _LaunchRampForegroundRailing extends BodyComponent size: sprite.originalSize / 10, anchor: Anchor.center, position: Vector2(22.8, 0), - priority: 4, ), ); } diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 0e5eb37a..c7302d0d 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -38,6 +38,7 @@ flutter: - assets/images/spaceship/rail/ - assets/images/spaceship/ramp/ - assets/images/chrome_dino/ + - assets/images/kicker/ flutter_gen: line_length: 80 diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index 59066fca..1801fa52 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -6,6 +6,7 @@ // https://opensource.org/licenses/MIT. import 'package:dashbook/dashbook.dart'; import 'package:flutter/material.dart'; +import 'package:sandbox/stories/kicker/stories.dart'; import 'package:sandbox/stories/stories.dart'; void main() { @@ -19,5 +20,6 @@ void main() { addBaseboardStories(dashbook); addChromeDinoStories(dashbook); addDashNestBumperStories(dashbook); + addKickerStories(dashbook); runApp(dashbook); } diff --git a/packages/pinball_components/sandbox/lib/stories/kicker/kicker_game.dart b/packages/pinball_components/sandbox/lib/stories/kicker/kicker_game.dart new file mode 100644 index 00000000..21c0cfb8 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/kicker/kicker_game.dart @@ -0,0 +1,39 @@ +import 'package:flame/extensions.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/ball/basic_ball_game.dart'; + +class KickerGame extends BasicBallGame { + KickerGame({ + required this.trace, + }) : super(color: const Color(0xFFFF0000)); + + static const info = ''' + Shows how Kickers are rendered. + + - Activate the "trace" parameter to overlay the body. + - Tap anywhere on the screen to spawn a ball into the game. +'''; + + final bool trace; + + @override + Future onLoad() async { + await super.onLoad(); + + final center = screenToWorld(camera.viewport.canvasSize! / 2); + + final leftKicker = Kicker(side: BoardSide.left) + ..initialPosition = Vector2(center.x - (Kicker.size.x * 2), center.y); + await add(leftKicker); + + final rightKicker = Kicker(side: BoardSide.right) + ..initialPosition = Vector2(center.x + (Kicker.size.x * 2), center.y); + await add(rightKicker); + + if (trace) { + leftKicker.trace(); + rightKicker.trace(); + } + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/kicker/stories.dart b/packages/pinball_components/sandbox/lib/stories/kicker/stories.dart new file mode 100644 index 00000000..f4a6bf91 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/kicker/stories.dart @@ -0,0 +1,17 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/kicker/kicker_game.dart'; + +void addKickerStories(Dashbook dashbook) { + dashbook.storiesOf('Kickers').add( + 'Basic', + (context) => GameWidget( + game: KickerGame( + trace: context.boolProperty('Trace', true), + ), + ), + codeLink: buildSourceLink('kicker_game/basic.dart'), + info: KickerGame.info, + ); +} diff --git a/packages/pinball_components/test/src/components/dino_walls_test.dart b/packages/pinball_components/test/src/components/dino_walls_test.dart index af80444b..bb85bc8e 100644 --- a/packages/pinball_components/test/src/components/dino_walls_test.dart +++ b/packages/pinball_components/test/src/components/dino_walls_test.dart @@ -1,6 +1,5 @@ // ignore_for_file: cascade_invocations -import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -15,7 +14,7 @@ void main() { flameTester.test( 'loads correctly', (game) async { - final dinoWalls = DinoWalls(position: Vector2.zero()); + final dinoWalls = DinoWalls(); await game.addFromBlueprint(dinoWalls); await game.ready(); diff --git a/packages/pinball_components/test/src/components/golden/kickers.png b/packages/pinball_components/test/src/components/golden/kickers.png new file mode 100644 index 00000000..23176923 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/kickers.png differ diff --git a/packages/pinball_components/test/src/components/kicker_test.dart b/packages/pinball_components/test/src/components/kicker_test.dart index 4af7b8b1..55802703 100644 --- a/packages/pinball_components/test/src/components/kicker_test.dart +++ b/packages/pinball_components/test/src/components/kicker_test.dart @@ -5,10 +5,34 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball_components/pinball_components.dart'; +import '../../helpers/helpers.dart'; + void main() { group('Kicker', () { - // TODO(alestiago): Include golden tests for left and right. - final flameTester = FlameTester(Forge2DGame.new); + final flameTester = FlameTester(TestGame.new); + + flameTester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + final leftKicker = Kicker( + side: BoardSide.left, + )..initialPosition = Vector2(-20, 0); + final rightKicker = Kicker( + side: BoardSide.right, + )..initialPosition = Vector2(20, 0); + + await game.addAll([leftKicker, rightKicker]); + await game.ready(); + game.camera.followVector2(Vector2.zero()); + }, + // TODO(ruimiguel): enable test when workflows are fixed. + //verify: (game, tester) async { + // await expectLater( + // find.byGame(), + // matchesGoldenFile('golden/kickers.png'), + // ); + //}, + ); flameTester.test( 'loads correctly', diff --git a/pubspec.lock b/pubspec.lock index ada9db4e..240c5a9f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,6 +29,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.2" + audioplayers: + dependency: transitive + description: + name: audioplayers + url: "https://pub.dartlang.org" + source: hosted + version: "0.20.1" bloc: dependency: "direct main" description: @@ -148,6 +155,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" file: dependency: transitive description: @@ -183,6 +197,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + flame_audio: + dependency: transitive + description: + name: flame_audio + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" flame_bloc: dependency: "direct main" description: @@ -259,6 +280,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" http_multi_server: dependency: transitive description: @@ -392,6 +420,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + path_provider: + dependency: transitive + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.12" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.5" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + pinball_audio: + dependency: "direct main" + description: + path: "packages/pinball_audio" + relative: true + source: path + version: "1.0.0+1" pinball_components: dependency: "direct main" description: @@ -406,6 +490,13 @@ packages: relative: true source: path version: "1.0.0+1" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" plugin_platform_interface: dependency: transitive description: @@ -420,6 +511,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.5.0" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.4" provider: dependency: transitive description: @@ -509,6 +607,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+2" term_glyph: dependency: transitive description: @@ -544,6 +649,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" vector_math: dependency: transitive description: @@ -586,6 +698,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.5.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0+1" yaml: dependency: transitive description: @@ -595,4 +721,4 @@ packages: version: "3.1.0" sdks: dart: ">=2.16.0 <3.0.0" - flutter: ">=2.5.0" + flutter: ">=2.8.0" diff --git a/pubspec.yaml b/pubspec.yaml index a0cca553..161afb85 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: intl: ^0.17.0 leaderboard_repository: path: packages/leaderboard_repository + pinball_audio: + path: packages/pinball_audio pinball_components: path: packages/pinball_components pinball_theme: diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index f8415a58..01b5fea6 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -9,20 +9,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/landing/landing.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import '../../helpers/mocks.dart'; void main() { group('App', () { late LeaderboardRepository leaderboardRepository; + late PinballAudio pinballAudio; setUp(() { leaderboardRepository = MockLeaderboardRepository(); + pinballAudio = MockPinballAudio(); }); testWidgets('renders LandingPage', (tester) async { await tester.pumpWidget( - App(leaderboardRepository: leaderboardRepository), + App( + leaderboardRepository: leaderboardRepository, + pinballAudio: pinballAudio, + ), ); expect(find.byType(LandingPage), findsOneWidget); }); diff --git a/test/game/components/board_test.dart b/test/game/components/board_test.dart index 2f51b2b1..9f2a5260 100644 --- a/test/game/components/board_test.dart +++ b/test/game/components/board_test.dart @@ -9,7 +9,7 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.create); + final flameTester = FlameTester(EmptyPinballGameTest.new); group('Board', () { flameTester.test( @@ -78,7 +78,6 @@ void main() { flameTester.test( 'one FlutterForest', (game) async { - // TODO(alestiago): change to [NestBumpers] once provided. final board = Board(); await game.ready(); await game.ensureAdd(board); diff --git a/test/game/components/bonus_word_test.dart b/test/game/components/bonus_word_test.dart index 7d73b6bc..f01fced9 100644 --- a/test/game/components/bonus_word_test.dart +++ b/test/game/components/bonus_word_test.dart @@ -4,24 +4,28 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flame/effects.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.create); + final flameTester = FlameTester(EmptyPinballGameTest.new); group('BonusWord', () { flameTester.test( 'loads the letters correctly', (game) async { - await game.ready(); + final bonusWord = BonusWord( + position: Vector2.zero(), + ); + await game.ensureAdd(bonusWord); - final bonusWord = game.children.whereType().first; - final letters = bonusWord.children.whereType(); + final letters = bonusWord.descendants().whereType(); expect(letters.length, equals(GameBloc.bonusWord.length)); }, ); @@ -89,6 +93,21 @@ void main() { }, ); + flameTester.test( + 'plays the google bonus sound', + (game) async { + when(() => state.bonusHistory).thenReturn([GameBonus.word]); + + final bonusWord = BonusWord(position: Vector2.zero()); + await game.ensureAdd(bonusWord); + await game.ready(); + + bonusWord.onNewState(state); + + verify(bonusWord.gameRef.audio.googleBonus).called(1); + }, + ); + flameTester.test( 'adds a color effect to reset the color when the sequence is finished', (game) async { @@ -118,7 +137,7 @@ void main() { }); group('BonusLetter', () { - final flameTester = FlameTester(PinballGameTest.create); + final flameTester = FlameTester(EmptyPinballGameTest.new); flameTester.test( 'loads correctly', @@ -195,11 +214,14 @@ void main() { group('bonus letter activation', () { late GameBloc gameBloc; + late PinballAudio pinballAudio; final flameBlocTester = FlameBlocTester( - // TODO(alestiago): Use TestGame once BonusLetter has controller. - gameBuilder: PinballGameTest.create, + gameBuilder: EmptyPinballGameTest.new, blocBuilder: () => gameBloc, + repositories: () => [ + RepositoryProvider.value(value: pinballAudio), + ], ); setUp(() { @@ -209,19 +231,28 @@ void main() { const Stream.empty(), initialState: const GameState.initial(), ); + + pinballAudio = MockPinballAudio(); + when(pinballAudio.googleBonus).thenAnswer((_) {}); }); flameBlocTester.testGameWidget( 'adds BonusLetterActivated to GameBloc when not activated', setUp: (game, tester) async { - await game.ready(); - final bonusLetter = game.descendants().whereType().first; + final bonusWord = BonusWord( + position: Vector2.zero(), + ); + await game.ensureAdd(bonusWord); - bonusLetter.activate(); - await game.ready(); - }, - verify: (game, tester) async { - verify(() => gameBloc.add(const BonusLetterActivated(0))).called(1); + final bonusLetters = + game.descendants().whereType().toList(); + for (var index = 0; index < bonusLetters.length; index++) { + final bonusLetter = bonusLetters[index]; + bonusLetter.activate(); + await game.ready(); + + verify(() => gameBloc.add(BonusLetterActivated(index))).called(1); + } }, ); @@ -285,25 +316,33 @@ void main() { ); flameBlocTester.testGameWidget( - 'only listens when there is a change on the letter status', + 'listens when there is a change on the letter status', setUp: (game, tester) async { - await game.ready(); - final bonusLetter = game.descendants().whereType().first; - bonusLetter.activate(); - }, - verify: (game, tester) async { - const state = GameState( - score: 0, - balls: 2, - activatedBonusLetters: [0], - activatedDashNests: {}, - bonusHistory: [], - ); - final bonusLetter = game.descendants().whereType().first; - expect( - bonusLetter.listenWhen(const GameState.initial(), state), - isTrue, + final bonusWord = BonusWord( + position: Vector2.zero(), ); + await game.ensureAdd(bonusWord); + + final bonusLetters = + game.descendants().whereType().toList(); + for (var index = 0; index < bonusLetters.length; index++) { + final bonusLetter = bonusLetters[index]; + bonusLetter.activate(); + await game.ready(); + + final state = GameState( + score: 0, + balls: 2, + activatedBonusLetters: [index], + activatedDashNests: const {}, + bonusHistory: const [], + ); + + expect( + bonusLetter.listenWhen(const GameState.initial(), state), + isTrue, + ); + } }, ); }); diff --git a/test/game/components/controlled_ball_test.dart b/test/game/components/controlled_ball_test.dart index f9494543..05056484 100644 --- a/test/game/components/controlled_ball_test.dart +++ b/test/game/components/controlled_ball_test.dart @@ -13,7 +13,7 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.create); + final flameTester = FlameTester(EmptyPinballGameTest.new); group('BonusBallController', () { late Ball ball; @@ -67,7 +67,7 @@ void main() { }); final flameBlocTester = FlameBlocTester( - gameBuilder: PinballGameTest.create, + gameBuilder: EmptyPinballGameTest.new, blocBuilder: () => gameBloc, ); @@ -155,13 +155,13 @@ void main() { await game.ensureAdd(ball); final state = MockGameState(); - when(() => state.balls).thenReturn(2); + when(() => state.balls).thenReturn(1); final previousBalls = game.descendants().whereType().toList(); controller.onNewState(state); await game.ready(); - final currentBalls = game.descendants().whereType(); + final currentBalls = game.descendants().whereType().toList(); expect(currentBalls.contains(ball), isFalse); expect(currentBalls.length, equals(previousBalls.length)); diff --git a/test/game/components/controlled_flipper_test.dart b/test/game/components/controlled_flipper_test.dart index eabeca5e..03c51830 100644 --- a/test/game/components/controlled_flipper_test.dart +++ b/test/game/components/controlled_flipper_test.dart @@ -10,7 +10,7 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.create); + final flameTester = FlameTester(EmptyPinballGameTest.new); group('FlipperController', () { group('onKeyEvent', () { diff --git a/test/game/components/flutter_forest_test.dart b/test/game/components/flutter_forest_test.dart index 33dbb991..60c55be9 100644 --- a/test/game/components/flutter_forest_test.dart +++ b/test/game/components/flutter_forest_test.dart @@ -25,7 +25,7 @@ void beginContact(Forge2DGame game, BodyComponent bodyA, BodyComponent bodyB) { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.create); + final flameTester = FlameTester(EmptyPinballGameTest.new); group('FlutterForest', () { flameTester.test( @@ -146,16 +146,15 @@ void main() { }); final flameBlocTester = FlameBlocTester( - gameBuilder: PinballGameTest.create, + gameBuilder: EmptyPinballGameTest.new, blocBuilder: () => gameBloc, ); flameBlocTester.testGameWidget( 'add DashNestActivated event', setUp: (game, tester) async { - await game.ready(); - final flutterForest = - game.descendants().whereType().first; + final flutterForest = FlutterForest(); + await game.ensureAdd(flutterForest); await game.ensureAdd(ball); final bumpers = @@ -177,15 +176,16 @@ void main() { final flutterForest = FlutterForest(); await game.ensureAdd(flutterForest); await game.ensureAdd(ball); + game.addContactCallback(BallScorePointsCallback(game)); - final bumpers = - flutterForest.descendants().whereType(); + final bumpers = flutterForest.descendants().whereType(); for (final bumper in bumpers) { beginContact(game, bumper, ball); - final points = (bumper as ScorePoints).points; verify( - () => gameBloc.add(Scored(points: points)), + () => gameBloc.add( + Scored(points: bumper.points), + ), ).called(1); } }, diff --git a/test/game/components/score_points_test.dart b/test/game/components/score_points_test.dart index f97bdada..8317f20c 100644 --- a/test/game/components/score_points_test.dart +++ b/test/game/components/score_points_test.dart @@ -2,6 +2,7 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; import '../../helpers/helpers.dart'; @@ -20,6 +21,7 @@ void main() { group('BallScorePointsCallback', () { late PinballGame game; late GameBloc bloc; + late PinballAudio audio; late Ball ball; late FakeScorePoints fakeScorePoints; @@ -27,6 +29,7 @@ void main() { game = MockPinballGame(); bloc = MockGameBloc(); ball = MockBall(); + audio = MockPinballAudio(); fakeScorePoints = FakeScorePoints(); }); @@ -38,7 +41,8 @@ void main() { test( 'emits Scored event with points', () { - when(game.read).thenReturn(bloc); + when(game.read).thenReturn(bloc); + when(() => game.audio).thenReturn(audio); BallScorePointsCallback(game).begin( ball, @@ -53,6 +57,22 @@ void main() { ).called(1); }, ); + + test( + 'plays a Score sound', + () { + when(game.read).thenReturn(bloc); + when(() => game.audio).thenReturn(audio); + + BallScorePointsCallback(game).begin( + ball, + fakeScorePoints, + FakeContact(), + ); + + verify(audio.score).called(1); + }, + ); }); }); } diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 52008074..f418bad0 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -12,8 +12,8 @@ import '../helpers/helpers.dart'; void main() { group('PinballGame', () { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.create); - final debugModeFlameTester = FlameTester(DebugPinballGameTest.create); + final flameTester = FlameTester(PinballGameTest.new); + final debugModeFlameTester = FlameTester(DebugPinballGameTest.new); // TODO(alestiago): test if [PinballGame] registers // [BallScorePointsCallback] once the following issue is resolved: diff --git a/test/helpers/builders.dart b/test/helpers/builders.dart index f78aebe7..d0eea644 100644 --- a/test/helpers/builders.dart +++ b/test/helpers/builders.dart @@ -7,13 +7,19 @@ class FlameBlocTester> FlameBlocTester({ required GameCreateFunction gameBuilder, required B Function() blocBuilder, + List Function()? repositories, }) : super( gameBuilder, pumpWidget: (gameWidget, tester) async { await tester.pumpWidget( BlocProvider.value( value: blocBuilder(), - child: gameWidget, + child: repositories == null + ? gameWidget + : MultiRepositoryProvider( + providers: repositories.call(), + child: gameWidget, + ), ), ); }, diff --git a/test/helpers/extensions.dart b/test/helpers/extensions.dart index b3c4c6f8..4731eec4 100644 --- a/test/helpers/extensions.dart +++ b/test/helpers/extensions.dart @@ -1,22 +1,29 @@ import 'package:pinball/game/game.dart'; import 'package:pinball_theme/pinball_theme.dart'; -/// [PinballGame] extension to reduce boilerplate in tests. -extension PinballGameTest on PinballGame { - /// Create [PinballGame] with default [PinballTheme]. - static PinballGame create() => PinballGame( - theme: const PinballTheme( - characterTheme: DashTheme(), - ), - )..images.prefix = ''; +import 'helpers.dart'; + +class PinballGameTest extends PinballGame { + PinballGameTest() + : super( + audio: MockPinballAudio(), + theme: const PinballTheme( + characterTheme: DashTheme(), + ), + ); +} + +class DebugPinballGameTest extends DebugPinballGame { + DebugPinballGameTest() + : super( + audio: MockPinballAudio(), + theme: const PinballTheme( + characterTheme: DashTheme(), + ), + ); } -/// [DebugPinballGame] extension to reduce boilerplate in tests. -extension DebugPinballGameTest on DebugPinballGame { - /// Create [PinballGame] with default [PinballTheme]. - static DebugPinballGame create() => DebugPinballGame( - theme: const PinballTheme( - characterTheme: DashTheme(), - ), - ); +class EmptyPinballGameTest extends PinballGameTest { + @override + Future onLoad() async {} } diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index f139bc7b..c0dec5f5 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -8,6 +8,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/leaderboard/leaderboard.dart'; import 'package:pinball/theme/theme.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; class MockPinballGame extends Mock implements PinballGame {} @@ -69,3 +70,5 @@ class MockFixture extends Mock implements Fixture {} class MockComponentSet extends Mock implements ComponentSet {} class MockDashNestBumper extends Mock implements DashNestBumper {} + +class MockPinballAudio extends Mock implements PinballAudio {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index d5e819b4..722dc44c 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -14,9 +14,18 @@ import 'package:mockingjay/mockingjay.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/theme/theme.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import 'helpers.dart'; +PinballAudio _buildDefaultPinballAudio() { + final audio = MockPinballAudio(); + + when(audio.load).thenAnswer((_) => Future.value()); + + return audio; +} + extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { @@ -24,31 +33,41 @@ extension PumpApp on WidgetTester { GameBloc? gameBloc, ThemeCubit? themeCubit, LeaderboardRepository? leaderboardRepository, + PinballAudio? pinballAudio, }) { - return pumpWidget( - RepositoryProvider.value( - value: leaderboardRepository ?? MockLeaderboardRepository(), - child: MultiBlocProvider( + return runAsync(() { + return pumpWidget( + MultiRepositoryProvider( providers: [ - BlocProvider.value( - value: themeCubit ?? MockThemeCubit(), + RepositoryProvider.value( + value: leaderboardRepository ?? MockLeaderboardRepository(), ), - BlocProvider.value( - value: gameBloc ?? MockGameBloc(), + RepositoryProvider.value( + value: pinballAudio ?? _buildDefaultPinballAudio(), ), ], - child: MaterialApp( - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, + child: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: themeCubit ?? MockThemeCubit(), + ), + BlocProvider.value( + value: gameBloc ?? MockGameBloc(), + ), ], - supportedLocales: AppLocalizations.supportedLocales, - home: navigator != null - ? MockNavigatorProvider(navigator: navigator, child: widget) - : widget, + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: navigator != null + ? MockNavigatorProvider(navigator: navigator, child: widget) + : widget, + ), ), ), - ), - ); + ); + }); } }