From c65dfb60012247522e4d6165407aa7444b21c39b Mon Sep 17 00:00:00 2001 From: Erick Date: Tue, 5 Apr 2022 12:36:36 -0300 Subject: [PATCH] feat: adds sound effects (#143) * feat: adding placeholder sound effects * feat: adding sound effects * fix: lint * fix: adding pinball audio to mock * fix: tests --- .github/workflows/pinball_audio.yaml | 19 +++ lib/app/view/app.dart | 17 ++- lib/game/components/bonus_word.dart | 5 +- lib/game/components/score_points.dart | 2 + lib/game/pinball_game.dart | 13 +- lib/game/view/pinball_game_page.dart | 20 ++- lib/gen/assets.gen.dart | 3 - lib/main_development.dart | 7 +- lib/main_production.dart | 7 +- lib/main_staging.dart | 7 +- packages/pinball_audio/.gitignore | 39 ++++++ packages/pinball_audio/README.md | 11 ++ packages/pinball_audio/analysis_options.yaml | 4 + packages/pinball_audio/assets/sfx/google.ogg | Bin 0 -> 12240 bytes packages/pinball_audio/assets/sfx/plim.ogg | Bin 0 -> 10600 bytes .../pinball_audio/lib/gen/assets.gen.dart | 69 ++++++++++ packages/pinball_audio/lib/pinball_audio.dart | 3 + .../pinball_audio/lib/src/pinball_audio.dart | 71 ++++++++++ packages/pinball_audio/pubspec.yaml | 28 ++++ .../pinball_audio/test/helpers/helpers.dart | 1 + .../pinball_audio/test/helpers/mocks.dart | 34 +++++ .../test/src/pinball_audio_test.dart | 110 +++++++++++++++ .../lib/gen/assets.gen.dart | 46 ------- pubspec.lock | 128 +++++++++++++++++- pubspec.yaml | 2 + test/app/view/app_test.dart | 8 +- test/game/components/bonus_word_test.dart | 24 ++++ test/game/components/score_points_test.dart | 22 ++- test/helpers/builders.dart | 8 +- test/helpers/extensions.dart | 4 + test/helpers/mocks.dart | 3 + test/helpers/pump_app.dart | 55 +++++--- 32 files changed, 685 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/pinball_audio.yaml create mode 100644 packages/pinball_audio/.gitignore create mode 100644 packages/pinball_audio/README.md create mode 100644 packages/pinball_audio/analysis_options.yaml create mode 100644 packages/pinball_audio/assets/sfx/google.ogg create mode 100644 packages/pinball_audio/assets/sfx/plim.ogg create mode 100644 packages/pinball_audio/lib/gen/assets.gen.dart create mode 100644 packages/pinball_audio/lib/pinball_audio.dart create mode 100644 packages/pinball_audio/lib/src/pinball_audio.dart create mode 100644 packages/pinball_audio/pubspec.yaml create mode 100644 packages/pinball_audio/test/helpers/helpers.dart create mode 100644 packages/pinball_audio/test/helpers/mocks.dart create mode 100644 packages/pinball_audio/test/src/pinball_audio_test.dart 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/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/pinball_game.dart b/lib/game/pinball_game.dart index 6f4a0c81..e09ab461 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -8,17 +8,20 @@ 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; + final PinballAudio audio; + late final Plunger plunger; @override @@ -109,7 +112,13 @@ class PinballGame extends Forge2DGame } 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 97be7f3e..370d8fcf 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 0000000000000000000000000000000000000000..dafaa8d41c29878463f742fd80d4cf5645ab065d GIT binary patch literal 12240 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 literal 0 HcmV?d00001 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/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index c928df79..48ddbaff 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -3,16 +3,12 @@ /// FlutterGen /// ***************************************************** -// ignore_for_file: directives_ordering,unnecessary_import - import 'package:flutter/widgets.dart'; class $AssetsImagesGen { const $AssetsImagesGen(); - /// File path: assets/images/ball.png AssetGenImage get ball => const AssetGenImage('assets/images/ball.png'); - $AssetsImagesBaseboardGen get baseboard => const $AssetsImagesBaseboardGen(); $AssetsImagesBoundaryGen get boundary => const $AssetsImagesBoundaryGen(); $AssetsImagesChromeDinoGen get chromeDino => @@ -21,11 +17,8 @@ class $AssetsImagesGen { const $AssetsImagesDashBumperGen(); $AssetsImagesDinoGen get dino => const $AssetsImagesDinoGen(); $AssetsImagesFlipperGen get flipper => const $AssetsImagesFlipperGen(); - - /// File path: assets/images/flutter_sign_post.png AssetGenImage get flutterSignPost => const AssetGenImage('assets/images/flutter_sign_post.png'); - $AssetsImagesLaunchRampGen get launchRamp => const $AssetsImagesLaunchRampGen(); $AssetsImagesSpaceshipGen get spaceship => const $AssetsImagesSpaceshipGen(); @@ -34,11 +27,8 @@ class $AssetsImagesGen { class $AssetsImagesBaseboardGen { const $AssetsImagesBaseboardGen(); - /// File path: assets/images/baseboard/left.png AssetGenImage get left => const AssetGenImage('assets/images/baseboard/left.png'); - - /// File path: assets/images/baseboard/right.png AssetGenImage get right => const AssetGenImage('assets/images/baseboard/right.png'); } @@ -46,11 +36,8 @@ class $AssetsImagesBaseboardGen { class $AssetsImagesBoundaryGen { const $AssetsImagesBoundaryGen(); - /// File path: assets/images/boundary/bottom.png AssetGenImage get bottom => const AssetGenImage('assets/images/boundary/bottom.png'); - - /// File path: assets/images/boundary/outer.png AssetGenImage get outer => const AssetGenImage('assets/images/boundary/outer.png'); } @@ -58,11 +45,8 @@ class $AssetsImagesBoundaryGen { class $AssetsImagesChromeDinoGen { const $AssetsImagesChromeDinoGen(); - /// File path: assets/images/chrome_dino/head.png AssetGenImage get head => const AssetGenImage('assets/images/chrome_dino/head.png'); - - /// File path: assets/images/chrome_dino/mouth.png AssetGenImage get mouth => const AssetGenImage('assets/images/chrome_dino/mouth.png'); } @@ -79,11 +63,8 @@ class $AssetsImagesDashBumperGen { class $AssetsImagesDinoGen { const $AssetsImagesDinoGen(); - /// File path: assets/images/dino/dino-land-bottom.png AssetGenImage get dinoLandBottom => const AssetGenImage('assets/images/dino/dino-land-bottom.png'); - - /// File path: assets/images/dino/dino-land-top.png AssetGenImage get dinoLandTop => const AssetGenImage('assets/images/dino/dino-land-top.png'); } @@ -91,11 +72,8 @@ class $AssetsImagesDinoGen { class $AssetsImagesFlipperGen { const $AssetsImagesFlipperGen(); - /// File path: assets/images/flipper/left.png AssetGenImage get left => const AssetGenImage('assets/images/flipper/left.png'); - - /// File path: assets/images/flipper/right.png AssetGenImage get right => const AssetGenImage('assets/images/flipper/right.png'); } @@ -103,11 +81,8 @@ class $AssetsImagesFlipperGen { class $AssetsImagesLaunchRampGen { const $AssetsImagesLaunchRampGen(); - /// File path: assets/images/launch_ramp/foreground-railing.png AssetGenImage get foregroundRailing => const AssetGenImage('assets/images/launch_ramp/foreground-railing.png'); - - /// File path: assets/images/launch_ramp/ramp.png AssetGenImage get ramp => const AssetGenImage('assets/images/launch_ramp/ramp.png'); } @@ -115,16 +90,12 @@ class $AssetsImagesLaunchRampGen { class $AssetsImagesSpaceshipGen { const $AssetsImagesSpaceshipGen(); - /// File path: assets/images/spaceship/bridge.png AssetGenImage get bridge => const AssetGenImage('assets/images/spaceship/bridge.png'); - $AssetsImagesSpaceshipRailGen get rail => const $AssetsImagesSpaceshipRailGen(); $AssetsImagesSpaceshipRampGen get ramp => const $AssetsImagesSpaceshipRampGen(); - - /// File path: assets/images/spaceship/saucer.png AssetGenImage get saucer => const AssetGenImage('assets/images/spaceship/saucer.png'); } @@ -132,11 +103,8 @@ class $AssetsImagesSpaceshipGen { class $AssetsImagesDashBumperAGen { const $AssetsImagesDashBumperAGen(); - /// File path: assets/images/dash_bumper/a/active.png AssetGenImage get active => const AssetGenImage('assets/images/dash_bumper/a/active.png'); - - /// File path: assets/images/dash_bumper/a/inactive.png AssetGenImage get inactive => const AssetGenImage('assets/images/dash_bumper/a/inactive.png'); } @@ -144,11 +112,8 @@ class $AssetsImagesDashBumperAGen { class $AssetsImagesDashBumperBGen { const $AssetsImagesDashBumperBGen(); - /// File path: assets/images/dash_bumper/b/active.png AssetGenImage get active => const AssetGenImage('assets/images/dash_bumper/b/active.png'); - - /// File path: assets/images/dash_bumper/b/inactive.png AssetGenImage get inactive => const AssetGenImage('assets/images/dash_bumper/b/inactive.png'); } @@ -156,11 +121,8 @@ class $AssetsImagesDashBumperBGen { class $AssetsImagesDashBumperMainGen { const $AssetsImagesDashBumperMainGen(); - /// File path: assets/images/dash_bumper/main/active.png AssetGenImage get active => const AssetGenImage('assets/images/dash_bumper/main/active.png'); - - /// File path: assets/images/dash_bumper/main/inactive.png AssetGenImage get inactive => const AssetGenImage('assets/images/dash_bumper/main/inactive.png'); } @@ -168,11 +130,8 @@ class $AssetsImagesDashBumperMainGen { class $AssetsImagesSpaceshipRailGen { const $AssetsImagesSpaceshipRailGen(); - /// File path: assets/images/spaceship/rail/foreground.png AssetGenImage get foreground => const AssetGenImage('assets/images/spaceship/rail/foreground.png'); - - /// File path: assets/images/spaceship/rail/main.png AssetGenImage get main => const AssetGenImage('assets/images/spaceship/rail/main.png'); } @@ -180,15 +139,10 @@ class $AssetsImagesSpaceshipRailGen { class $AssetsImagesSpaceshipRampGen { const $AssetsImagesSpaceshipRampGen(); - /// File path: assets/images/spaceship/ramp/main.png AssetGenImage get main => const AssetGenImage('assets/images/spaceship/ramp/main.png'); - - /// File path: assets/images/spaceship/ramp/railing-background.png AssetGenImage get railingBackground => const AssetGenImage( 'assets/images/spaceship/ramp/railing-background.png'); - - /// File path: assets/images/spaceship/ramp/railing-foreground.png AssetGenImage get railingForeground => const AssetGenImage( 'assets/images/spaceship/ramp/railing-foreground.png'); } 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/bonus_word_test.dart b/test/game/components/bonus_word_test.dart index 7d73b6bc..6b1af085 100644 --- a/test/game/components/bonus_word_test.dart +++ b/test/game/components/bonus_word_test.dart @@ -4,9 +4,11 @@ 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'; @@ -89,6 +91,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 { @@ -195,11 +212,15 @@ 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, blocBuilder: () => gameBloc, + repositories: () => [ + RepositoryProvider.value(value: pinballAudio), + ], ); setUp(() { @@ -209,6 +230,9 @@ void main() { const Stream.empty(), initialState: const GameState.initial(), ); + + pinballAudio = MockPinballAudio(); + when(pinballAudio.googleBonus).thenAnswer((_) {}); }); flameBlocTester.testGameWidget( 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/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..a5039381 100644 --- a/test/helpers/extensions.dart +++ b/test/helpers/extensions.dart @@ -1,6 +1,8 @@ import 'package:pinball/game/game.dart'; import 'package:pinball_theme/pinball_theme.dart'; +import 'helpers.dart'; + /// [PinballGame] extension to reduce boilerplate in tests. extension PinballGameTest on PinballGame { /// Create [PinballGame] with default [PinballTheme]. @@ -8,6 +10,7 @@ extension PinballGameTest on PinballGame { theme: const PinballTheme( characterTheme: DashTheme(), ), + audio: MockPinballAudio(), )..images.prefix = ''; } @@ -18,5 +21,6 @@ extension DebugPinballGameTest on DebugPinballGame { theme: const PinballTheme( characterTheme: DashTheme(), ), + audio: MockPinballAudio(), ); } 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, + ), ), ), - ), - ); + ); + }); } }