diff --git a/lib/game/behaviors/behaviors.dart b/lib/game/behaviors/behaviors.dart index bb196cec..bca8be14 100644 --- a/lib/game/behaviors/behaviors.dart +++ b/lib/game/behaviors/behaviors.dart @@ -6,4 +6,5 @@ export 'camera_focusing_behavior.dart'; export 'character_selection_behavior.dart'; export 'cow_bumper_noise_behavior.dart'; export 'kicker_noise_behavior.dart'; +export 'rollover_noise_behavior.dart'; export 'scoring_behavior.dart'; diff --git a/lib/game/behaviors/character_selection_behavior.dart b/lib/game/behaviors/character_selection_behavior.dart index e62438f6..27003d75 100644 --- a/lib/game/behaviors/character_selection_behavior.dart +++ b/lib/game/behaviors/character_selection_behavior.dart @@ -2,8 +2,6 @@ import 'package:flame/components.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; -import 'package:platform_helper/platform_helper.dart'; /// Updates the [ArcadeBackground] and launch [Ball] to reflect character /// selections. @@ -13,14 +11,12 @@ class CharacterSelectionBehavior extends Component HasGameRef { @override void onNewState(CharacterThemeState state) { - if (!readProvider().isMobile) { - gameRef - .descendants() - .whereType() - .single - .bloc - .onCharacterSelected(state.characterTheme); - } + gameRef + .descendants() + .whereType() + .single + .bloc + .onCharacterSelected(state.characterTheme); gameRef .descendants() .whereType() diff --git a/lib/game/behaviors/rollover_noise_behavior.dart b/lib/game/behaviors/rollover_noise_behavior.dart new file mode 100644 index 00000000..06b2f77a --- /dev/null +++ b/lib/game/behaviors/rollover_noise_behavior.dart @@ -0,0 +1,13 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class RolloverNoiseBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + readProvider().play(PinballAudio.rollover); + } +} diff --git a/lib/game/bloc/game_event.dart b/lib/game/bloc/game_event.dart index bc50ac57..126ba299 100644 --- a/lib/game/bloc/game_event.dart +++ b/lib/game/bloc/game_event.dart @@ -64,10 +64,3 @@ class GameOver extends GameEvent { @override List get props => []; } - -class Replayed extends GameEvent { - const Replayed(); - - @override - List get props => []; -} diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index e463e16b..8fcab789 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -52,7 +52,7 @@ class GameState extends Equatable { totalScore = 0, roundScore = 0, multiplier = 1, - rounds = 1, + rounds = 3, bonusHistory = const []; /// The score for the current round of the game. diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 969ea1ac..103f029c 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,7 +1,6 @@ export 'android_acres/android_acres.dart'; export 'backbox/backbox.dart'; export 'bottom_group.dart'; -export 'controlled_plunger.dart'; export 'dino_desert/dino_desert.dart'; export 'drain/drain.dart'; export 'flutter_forest/flutter_forest.dart'; diff --git a/lib/game/components/controlled_plunger.dart b/lib/game/components/controlled_plunger.dart deleted file mode 100644 index f709de66..00000000 --- a/lib/game/components/controlled_plunger.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame_bloc/flame_bloc.dart'; -import 'package:flutter/services.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_audio/pinball_audio.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template controlled_plunger} -/// A [Plunger] with a [PlungerController] attached. -/// {@endtemplate} -class ControlledPlunger extends Plunger with Controls { - /// {@macro controlled_plunger} - ControlledPlunger({required double compressionDistance}) - : super(compressionDistance: compressionDistance) { - controller = PlungerController(this); - } - - @override - void release() { - super.release(); - - add(PlungerNoiseBehavior()); - } -} - -/// A behavior attached to the plunger when it launches the ball which plays the -/// related sound effects. -class PlungerNoiseBehavior extends Component { - @override - Future onLoad() async { - await super.onLoad(); - readProvider().play(PinballAudio.launcher); - } - - @override - void update(double dt) { - super.update(dt); - removeFromParent(); - } -} - -/// {@template plunger_controller} -/// A [ComponentController] that controls a [Plunger]s movement. -/// {@endtemplate} -class PlungerController extends ComponentController - with KeyboardHandler, FlameBlocReader { - /// {@macro plunger_controller} - PlungerController(Plunger plunger) : super(plunger); - - /// The [LogicalKeyboardKey]s that will control the [Flipper]. - /// - /// [onKeyEvent] method listens to when one of these keys is pressed. - static const List _keys = [ - LogicalKeyboardKey.arrowDown, - LogicalKeyboardKey.space, - LogicalKeyboardKey.keyS, - ]; - - @override - bool onKeyEvent( - RawKeyEvent event, - Set keysPressed, - ) { - if (bloc.state.status.isGameOver) return true; - if (!_keys.contains(event.logicalKey)) return true; - - if (event is RawKeyDownEvent) { - component.pull(); - } else if (event is RawKeyUpEvent) { - component.release(); - } - - return false; - } -} diff --git a/lib/game/components/game_bloc_status_listener.dart b/lib/game/components/game_bloc_status_listener.dart index 3343b5ff..c463cd94 100644 --- a/lib/game/components/game_bloc_status_listener.dart +++ b/lib/game/components/game_bloc_status_listener.dart @@ -5,6 +5,7 @@ import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; +import 'package:platform_helper/platform_helper.dart'; /// Listens to the [GameBloc] and updates the game accordingly. class GameBlocStatusListener extends Component @@ -25,7 +26,11 @@ class GameBlocStatusListener extends Component gameRef .descendants() .whereType() - .forEach(_addFlipperKeyControls); + .forEach(_addFlipperBehaviors); + gameRef + .descendants() + .whereType() + .forEach(_addPlungerBehaviors); gameRef.overlays.remove(PinballGame.playButtonOverlay); gameRef.overlays.remove(PinballGame.replayButtonOverlay); @@ -42,7 +47,11 @@ class GameBlocStatusListener extends Component gameRef .descendants() .whereType() - .forEach(_removeFlipperKeyControls); + .forEach(_removeFlipperBehaviors); + gameRef + .descendants() + .whereType() + .forEach(_removePlungerBehaviors); break; } } @@ -56,13 +65,42 @@ class GameBlocStatusListener extends Component .onReset(); } - void _addFlipperKeyControls(Flipper flipper) { - flipper - ..add(FlipperKeyControllingBehavior()) - ..moveDown(); + void _addPlungerBehaviors(Plunger plunger) { + final platformHelper = readProvider(); + const pullingStrength = 7.0; + final provider = + plunger.firstChild>()!; + + if (platformHelper.isMobile) { + provider.add( + PlungerAutoPullingBehavior(strength: pullingStrength), + ); + } else { + provider.addAll( + [ + PlungerKeyControllingBehavior(), + PlungerPullingBehavior(strength: pullingStrength), + ], + ); + } } - void _removeFlipperKeyControls(Flipper flipper) => flipper + void _removePlungerBehaviors(Plunger plunger) { + plunger + .descendants() + .whereType() + .forEach(plunger.remove); + plunger + .descendants() + .whereType() + .forEach(plunger.remove); + } + + void _addFlipperBehaviors(Flipper flipper) => flipper + ..add(FlipperKeyControllingBehavior()) + ..moveDown(); + + void _removeFlipperBehaviors(Flipper flipper) => flipper .descendants() .whereType() .forEach(flipper.remove); diff --git a/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart b/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart index c5ff90cf..2313e921 100644 --- a/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart +++ b/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart @@ -19,6 +19,7 @@ class GoogleWordBonusBehavior extends Component { .add(const BonusActivated(GameBonus.googleWord)); readBloc().onReset(); add(BonusBallSpawningBehavior()); + add(GoogleWordAnimatingBehavior()); }, ), ); diff --git a/lib/game/components/google_gallery/google_gallery.dart b/lib/game/components/google_gallery/google_gallery.dart index 0b3d4b10..ec3f9e36 100644 --- a/lib/game/components/google_gallery/google_gallery.dart +++ b/lib/game/components/google_gallery/google_gallery.dart @@ -22,12 +22,14 @@ class GoogleGallery extends Component with ZIndex { side: BoardSide.right, children: [ ScoringContactBehavior(points: Points.fiveThousand), + RolloverNoiseBehavior(), ], ), GoogleRollover( side: BoardSide.left, children: [ ScoringContactBehavior(points: Points.fiveThousand), + RolloverNoiseBehavior(), ], ), GoogleWord(position: Vector2(-4.45, 1.8)), diff --git a/lib/game/components/launcher.dart b/lib/game/components/launcher.dart index 4729515a..99b44a80 100644 --- a/lib/game/components/launcher.dart +++ b/lib/game/components/launcher.dart @@ -1,5 +1,4 @@ import 'package:flame/components.dart'; -import 'package:pinball/game/components/components.dart'; import 'package:pinball_components/pinball_components.dart' hide Assets; /// {@template launcher} @@ -13,8 +12,7 @@ class Launcher extends Component { children: [ LaunchRamp(), Flapper(), - ControlledPlunger(compressionDistance: 9.2) - ..initialPosition = Vector2(41, 43.7), + Plunger()..initialPosition = Vector2(41, 43.7), RocketSpriteComponent()..position = Vector2(42.8, 62.3), ], ); diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index fccd494e..0d0ef26a 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -11,8 +11,7 @@ extension PinballGameAssetsX on PinballGame { const sparkyTheme = SparkyTheme(); const androidTheme = AndroidTheme(); const dinoTheme = DinoTheme(); - - final gameAssets = [ + return [ images.load(components.Assets.images.boardBackground.keyName), images.load(components.Assets.images.ball.flameEffect.keyName), images.load(components.Assets.images.signpost.inactive.keyName), @@ -155,14 +154,10 @@ extension PinballGameAssetsX on PinballGame { images.load(dinoTheme.ball.keyName), images.load(sparkyTheme.leaderboardIcon.keyName), images.load(sparkyTheme.ball.keyName), + images.load(androidTheme.background.keyName), + images.load(dashTheme.background.keyName), + images.load(dinoTheme.background.keyName), + images.load(sparkyTheme.background.keyName), ]; - - return (platformHelper.isMobile) ? gameAssets : gameAssets - ..addAll([ - images.load(androidTheme.background.keyName), - images.load(dashTheme.background.keyName), - images.load(dinoTheme.background.keyName), - images.load(sparkyTheme.background.keyName), - ]); } } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index d2f4cc52..47896fc5 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -128,6 +128,7 @@ class PinballGame extends PinballForge2DGame SkillShot( children: [ ScoringContactBehavior(points: Points.oneMillion), + RolloverNoiseBehavior(), ], ), AndroidAcres(), @@ -158,9 +159,15 @@ class PinballGame extends PinballForge2DGame final rocket = descendants().whereType().first; final bounds = rocket.topLeftPosition & rocket.size; - // NOTE: As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 we need to check it at the highest level manually. - if (bounds.contains(info.eventPosition.game.toOffset())) { - descendants().whereType().single.pullFor(2); + // NOTE: As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 + // we need to check it at the highest level manually. + final tappedRocket = bounds.contains(info.eventPosition.game.toOffset()); + if (tappedRocket) { + descendants() + .whereType>() + .first + .bloc + .pulled(); } else { final leftSide = info.eventPosition.widget.x < canvasSize.x / 2; focusedBoardSide[pointerId] = diff --git a/lib/select_character/view/character_selection_page.dart b/lib/select_character/view/character_selection_page.dart index 2355d6cc..5504459f 100644 --- a/lib/select_character/view/character_selection_page.dart +++ b/lib/select_character/view/character_selection_page.dart @@ -132,10 +132,15 @@ class _Character extends StatelessWidget { Widget build(BuildContext context) { return Expanded( child: Opacity( - opacity: isSelected ? 1 : 0.3, + opacity: isSelected ? 1 : 0.4, child: TextButton( onPressed: () => context.read().characterSelected(character), + style: ButtonStyle( + overlayColor: MaterialStateProperty.all( + PinballColors.transparent, + ), + ), child: character.icon.image(fit: BoxFit.contain), ), ), diff --git a/packages/pinball_audio/assets/sfx/rollover.mp3 b/packages/pinball_audio/assets/sfx/rollover.mp3 new file mode 100644 index 00000000..543a3560 Binary files /dev/null and b/packages/pinball_audio/assets/sfx/rollover.mp3 differ diff --git a/packages/pinball_audio/lib/gen/assets.gen.dart b/packages/pinball_audio/lib/gen/assets.gen.dart index c8b66234..0b8fb20b 100644 --- a/packages/pinball_audio/lib/gen/assets.gen.dart +++ b/packages/pinball_audio/lib/gen/assets.gen.dart @@ -26,6 +26,7 @@ class $AssetsSfxGen { String get kickerA => 'assets/sfx/kicker_a.mp3'; String get kickerB => 'assets/sfx/kicker_b.mp3'; String get launcher => 'assets/sfx/launcher.mp3'; + String get rollover => 'assets/sfx/rollover.mp3'; String get sparky => 'assets/sfx/sparky.mp3'; } diff --git a/packages/pinball_audio/lib/src/pinball_audio.dart b/packages/pinball_audio/lib/src/pinball_audio.dart index 9682b520..e0e69988 100644 --- a/packages/pinball_audio/lib/src/pinball_audio.dart +++ b/packages/pinball_audio/lib/src/pinball_audio.dart @@ -33,6 +33,9 @@ enum PinballAudio { /// Kicker. kicker, + /// Rollover. + rollover, + /// Sparky. sparky, @@ -56,7 +59,7 @@ typedef CreateAudioPool = Future Function( }); /// Defines the contract for playing a single audio. -typedef PlaySingleAudio = Future Function(String); +typedef PlaySingleAudio = Future Function(String, {double volume}); /// Defines the contract for looping a single audio. typedef LoopSingleAudio = Future Function(String, {double volume}); @@ -81,18 +84,20 @@ class _SimplePlayAudio extends _Audio { required this.preCacheSingleAudio, required this.playSingleAudio, required this.path, + this.volume, }); final PreCacheSingleAudio preCacheSingleAudio; final PlaySingleAudio playSingleAudio; final String path; + final double? volume; @override Future load() => preCacheSingleAudio(prefixFile(path)); @override void play() { - playSingleAudio(prefixFile(path)); + playSingleAudio(prefixFile(path), volume: volume ?? 1); } } @@ -266,6 +271,12 @@ class PinballAudioPlayer { playSingleAudio: _playSingleAudio, path: Assets.sfx.launcher, ), + PinballAudio.rollover: _SimplePlayAudio( + preCacheSingleAudio: _preCacheSingleAudio, + playSingleAudio: _playSingleAudio, + path: Assets.sfx.rollover, + volume: 0.3, + ), PinballAudio.ioPinballVoiceOver: _SimplePlayAudio( preCacheSingleAudio: _preCacheSingleAudio, playSingleAudio: _playSingleAudio, diff --git a/packages/pinball_audio/test/src/pinball_audio_test.dart b/packages/pinball_audio/test/src/pinball_audio_test.dart index 3e147329..769a880d 100644 --- a/packages/pinball_audio/test/src/pinball_audio_test.dart +++ b/packages/pinball_audio/test/src/pinball_audio_test.dart @@ -29,15 +29,15 @@ class _MockConfigureAudioCache extends Mock { } class _MockPlaySingleAudio extends Mock { - Future onCall(String url); + Future onCall(String path, {double volume}); } class _MockLoopSingleAudio extends Mock { - Future onCall(String url, {double volume}); + Future onCall(String path, {double volume}); } abstract class _PreCacheSingleAudio { - Future onCall(String url); + Future onCall(String path); } class _MockPreCacheSingleAudio extends Mock implements _PreCacheSingleAudio {} @@ -74,7 +74,8 @@ void main() { when(() => configureAudioCache.onCall(any())).thenAnswer((_) {}); playSingleAudio = _MockPlaySingleAudio(); - when(() => playSingleAudio.onCall(any())).thenAnswer((_) async {}); + when(() => playSingleAudio.onCall(any(), volume: any(named: 'volume'))) + .thenAnswer((_) async {}); loopSingleAudio = _MockLoopSingleAudio(); when(() => loopSingleAudio.onCall(any(), volume: any(named: 'volume'))) @@ -195,6 +196,10 @@ void main() { () => preCacheSingleAudio .onCall('packages/pinball_audio/assets/sfx/launcher.mp3'), ).called(1); + verify( + () => preCacheSingleAudio + .onCall('packages/pinball_audio/assets/sfx/rollover.mp3'), + ).called(1); verify( () => preCacheSingleAudio .onCall('packages/pinball_audio/assets/sfx/cow_moo.mp3'), @@ -346,8 +351,10 @@ void main() { audioPlayer.play(PinballAudio.google); verify( - () => playSingleAudio - .onCall('packages/pinball_audio/${Assets.sfx.google}'), + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.google}', + volume: any(named: 'volume'), + ), ).called(1); }); }); @@ -358,8 +365,10 @@ void main() { audioPlayer.play(PinballAudio.sparky); verify( - () => playSingleAudio - .onCall('packages/pinball_audio/${Assets.sfx.sparky}'), + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.sparky}', + volume: any(named: 'volume'), + ), ).called(1); }); }); @@ -370,8 +379,10 @@ void main() { audioPlayer.play(PinballAudio.dino); verify( - () => playSingleAudio - .onCall('packages/pinball_audio/${Assets.sfx.dino}'), + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.dino}', + volume: any(named: 'volume'), + ), ).called(1); }); }); @@ -382,8 +393,10 @@ void main() { audioPlayer.play(PinballAudio.android); verify( - () => playSingleAudio - .onCall('packages/pinball_audio/${Assets.sfx.android}'), + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.android}', + volume: any(named: 'volume'), + ), ).called(1); }); }); @@ -394,8 +407,10 @@ void main() { audioPlayer.play(PinballAudio.dash); verify( - () => playSingleAudio - .onCall('packages/pinball_audio/${Assets.sfx.dash}'), + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.dash}', + volume: any(named: 'volume'), + ), ).called(1); }); }); @@ -406,8 +421,24 @@ void main() { audioPlayer.play(PinballAudio.launcher); verify( - () => playSingleAudio - .onCall('packages/pinball_audio/${Assets.sfx.launcher}'), + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.launcher}', + volume: any(named: 'volume'), + ), + ).called(1); + }); + }); + + group('rollover', () { + test('plays the correct file', () async { + await Future.wait(audioPlayer.load()); + audioPlayer.play(PinballAudio.rollover); + + verify( + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.rollover}', + volume: .3, + ), ).called(1); }); }); @@ -420,6 +451,7 @@ void main() { verify( () => playSingleAudio.onCall( 'packages/pinball_audio/${Assets.sfx.ioPinballVoiceOver}', + volume: any(named: 'volume'), ), ).called(1); }); @@ -433,6 +465,7 @@ void main() { verify( () => playSingleAudio.onCall( 'packages/pinball_audio/${Assets.sfx.gameOverVoiceOver}', + volume: any(named: 'volume'), ), ).called(1); }); diff --git a/packages/pinball_components/lib/src/components/bumping_behavior.dart b/packages/pinball_components/lib/src/components/bumping_behavior.dart index 17931838..0d259860 100644 --- a/packages/pinball_components/lib/src/components/bumping_behavior.dart +++ b/packages/pinball_components/lib/src/components/bumping_behavior.dart @@ -7,7 +7,9 @@ import 'package:pinball_flame/pinball_flame.dart'; /// {@endtemplate} class BumpingBehavior extends ContactBehavior { /// {@macro bumping_behavior} - BumpingBehavior({required double strength}) : _strength = strength; + BumpingBehavior({required double strength}) + : assert(strength >= 0, "Strength can't be negative."), + _strength = strength; /// Determines how strong the bump is. final double _strength; diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 1116ee88..8fd74268 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -27,7 +27,7 @@ export 'launch_ramp.dart'; export 'layer_sensor/layer_sensor.dart'; export 'multiball/multiball.dart'; export 'multiplier/multiplier.dart'; -export 'plunger.dart'; +export 'plunger/plunger.dart'; export 'rocket.dart'; export 'score_component/score_component.dart'; export 'signpost/signpost.dart'; diff --git a/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart b/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart index 95566e75..ca4fcece 100644 --- a/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart +++ b/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart @@ -14,7 +14,21 @@ class FlipperKeyControllingBehavior extends Component @override Future onLoad() async { await super.onLoad(); - _keys = parent.side.flipperKeys; + + switch (parent.side) { + case BoardSide.left: + _keys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.keyA, + ]; + break; + case BoardSide.right: + _keys = [ + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.keyD, + ]; + break; + } } @override @@ -33,20 +47,3 @@ class FlipperKeyControllingBehavior extends Component return false; } } - -extension on BoardSide { - List get flipperKeys { - switch (this) { - case BoardSide.left: - return [ - LogicalKeyboardKey.arrowLeft, - LogicalKeyboardKey.keyA, - ]; - case BoardSide.right: - return [ - LogicalKeyboardKey.arrowRight, - LogicalKeyboardKey.keyD, - ]; - } - } -} diff --git a/packages/pinball_components/lib/src/components/google_word/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/google_word/behaviors/behaviors.dart new file mode 100644 index 00000000..02dab3a8 --- /dev/null +++ b/packages/pinball_components/lib/src/components/google_word/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'google_word_animating_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/google_word/behaviors/google_word_animating_behavior.dart b/packages/pinball_components/lib/src/components/google_word/behaviors/google_word_animating_behavior.dart new file mode 100644 index 00000000..2119c2f8 --- /dev/null +++ b/packages/pinball_components/lib/src/components/google_word/behaviors/google_word_animating_behavior.dart @@ -0,0 +1,24 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class GoogleWordAnimatingBehavior extends TimerComponent + with FlameBlocReader { + GoogleWordAnimatingBehavior() : super(period: 0.35, repeat: true); + + final _maxBlinks = 7; + int _blinks = 0; + + @override + void onTick() { + super.onTick(); + if (_blinks != _maxBlinks * 2) { + bloc.switched(); + _blinks++; + } else { + timer.stop(); + bloc.onReset(); + shouldRemove = true; + } + } +} diff --git a/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart b/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart index 154d550d..cd69fc9d 100644 --- a/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart +++ b/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart @@ -11,11 +11,6 @@ class GoogleWordCubit extends Cubit { int _lastLitLetter = 0; - void onReset() { - emit(GoogleWordState.initial()); - _lastLitLetter = 0; - } - void onRolloverContacted() { final spriteStatesMap = {...state.letterSpriteStates}; if (_lastLitLetter < _lettersInGoogle) { @@ -27,4 +22,54 @@ class GoogleWordCubit extends Cubit { _lastLitLetter++; } } + + void switched() { + switch (state.letterSpriteStates[0]!) { + case GoogleLetterSpriteState.lit: + emit( + GoogleWordState( + letterSpriteStates: { + for (int i = 0; i < _lettersInGoogle; i++) + if (i.isEven) + i: GoogleLetterSpriteState.dimmed + else + i: GoogleLetterSpriteState.lit + }, + ), + ); + break; + case GoogleLetterSpriteState.dimmed: + emit( + GoogleWordState( + letterSpriteStates: { + for (int i = 0; i < _lettersInGoogle; i++) + if (i.isEven) + i: GoogleLetterSpriteState.lit + else + i: GoogleLetterSpriteState.dimmed + }, + ), + ); + break; + } + } + + void onBonusAwarded() { + emit( + GoogleWordState( + letterSpriteStates: { + for (int i = 0; i < _lettersInGoogle; i++) + if (i.isEven) + i: GoogleLetterSpriteState.lit + else + i: GoogleLetterSpriteState.dimmed + }, + ), + ); + } + + void onReset() { + emit(GoogleWordState.initial()); + _lastLitLetter = 0; + } } diff --git a/packages/pinball_components/lib/src/components/google_word/google_word.dart b/packages/pinball_components/lib/src/components/google_word/google_word.dart index 72126d2c..f9c93e2c 100644 --- a/packages/pinball_components/lib/src/components/google_word/google_word.dart +++ b/packages/pinball_components/lib/src/components/google_word/google_word.dart @@ -1,6 +1,7 @@ import 'package:flame/components.dart'; import 'package:pinball_components/pinball_components.dart'; +export 'behaviors/behaviors.dart'; export 'cubit/google_word_cubit.dart'; /// {@template google_word} diff --git a/packages/pinball_components/lib/src/components/plunger.dart b/packages/pinball_components/lib/src/components/plunger.dart deleted file mode 100644 index 6f38eb37..00000000 --- a/packages/pinball_components/lib/src/components/plunger.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/material.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template plunger} -/// [Plunger] serves as a spring, that shoots the ball on the right side of the -/// play field. -/// -/// [Plunger] ignores gravity so the player controls its downward [pull]. -/// {@endtemplate} -class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex { - /// {@macro plunger} - Plunger({ - required this.compressionDistance, - }) : super( - renderBody: false, - children: [_PlungerSpriteAnimationGroupComponent()], - ) { - zIndex = ZIndexes.plunger; - layer = Layer.launcher; - } - - /// Creates a [Plunger] without any children. - /// - /// This can be used for testing [Plunger]'s behaviors in isolation. - @visibleForTesting - Plunger.test({required this.compressionDistance}); - - /// Distance the plunger can lower. - final double compressionDistance; - - List _createFixtureDefs() { - final fixturesDef = []; - - final leftShapeVertices = [ - Vector2(0, 0), - Vector2(-1.8, 0), - Vector2(-1.8, -2.2), - Vector2(0, -0.3), - ]..map((vector) => vector.rotate(BoardDimensions.perspectiveAngle)) - .toList(); - final leftTriangleShape = PolygonShape()..set(leftShapeVertices); - - final leftTriangleFixtureDef = FixtureDef(leftTriangleShape)..density = 80; - fixturesDef.add(leftTriangleFixtureDef); - - final rightShapeVertices = [ - Vector2(0, 0), - Vector2(1.8, 0), - Vector2(1.8, -2.2), - Vector2(0, -0.3), - ]..map((vector) => vector.rotate(BoardDimensions.perspectiveAngle)) - .toList(); - final rightTriangleShape = PolygonShape()..set(rightShapeVertices); - - final rightTriangleFixtureDef = FixtureDef(rightTriangleShape) - ..density = 80; - fixturesDef.add(rightTriangleFixtureDef); - - return fixturesDef; - } - - @override - Body createBody() { - final bodyDef = BodyDef( - position: initialPosition, - userData: this, - type: BodyType.dynamic, - gravityScale: Vector2.zero(), - ); - - final body = world.createBody(bodyDef); - _createFixtureDefs().forEach(body.createFixture); - return body; - } - - var _pullingDownTime = 0.0; - - /// Pulls the plunger down for the given amount of [seconds]. - // ignore: use_setters_to_change_properties - void pullFor(double seconds) { - _pullingDownTime = seconds; - } - - /// Set a constant downward velocity on the [Plunger]. - void pull() { - final sprite = firstChild<_PlungerSpriteAnimationGroupComponent>()!; - - body.linearVelocity = Vector2(0, 7); - sprite.pull(); - } - - /// Set an upward velocity on the [Plunger]. - /// - /// The velocity's magnitude depends on how far the [Plunger] has been pulled - /// from its original [initialPosition]. - void release() { - final sprite = firstChild<_PlungerSpriteAnimationGroupComponent>()!; - - _pullingDownTime = 0; - final velocity = (initialPosition.y - body.position.y) * 11; - body.linearVelocity = Vector2(0, velocity); - sprite.release(); - } - - @override - void update(double dt) { - // Ensure that we only pull or release when the time is greater than zero. - if (_pullingDownTime > 0) { - _pullingDownTime -= PinballForge2DGame.clampDt(dt); - if (_pullingDownTime <= 0) { - release(); - } else { - pull(); - } - } - super.update(dt); - } - - /// Anchors the [Plunger] to the [PrismaticJoint] that controls its vertical - /// motion. - Future _anchorToJoint() async { - final anchor = PlungerAnchor(plunger: this); - await add(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: this, - anchor: anchor, - ); - - world.createJoint( - PrismaticJoint(jointDef)..setLimits(-compressionDistance, 0), - ); - } - - @override - Future onLoad() async { - await super.onLoad(); - await _anchorToJoint(); - } -} - -/// Animation states associated with a [Plunger]. -enum _PlungerAnimationState { - /// Pull state. - pull, - - /// Release state. - release, -} - -/// Animations for pulling and releasing [Plunger]. -class _PlungerSpriteAnimationGroupComponent - extends SpriteAnimationGroupComponent<_PlungerAnimationState> - with HasGameRef { - _PlungerSpriteAnimationGroupComponent() - : super( - anchor: Anchor.center, - position: Vector2(1.87, 14.9), - ); - - void pull() { - if (current != _PlungerAnimationState.pull) { - animation?.reset(); - } - current = _PlungerAnimationState.pull; - } - - void release() { - if (current != _PlungerAnimationState.release) { - animation?.reset(); - } - current = _PlungerAnimationState.release; - } - - @override - Future onLoad() async { - await super.onLoad(); - final spriteSheet = await gameRef.images.load( - Assets.images.plunger.plunger.keyName, - ); - const amountPerRow = 20; - const amountPerColumn = 1; - final textureSize = Vector2( - spriteSheet.width / amountPerRow, - spriteSheet.height / amountPerColumn, - ); - size = textureSize / 10; - final pullAnimation = SpriteAnimation.fromFrameData( - spriteSheet, - SpriteAnimationData.sequenced( - amount: amountPerRow * amountPerColumn ~/ 2, - amountPerRow: amountPerRow ~/ 2, - stepTime: 1 / 24, - textureSize: textureSize, - texturePosition: Vector2.zero(), - loop: false, - ), - ); - animations = { - _PlungerAnimationState.release: pullAnimation.reversed(), - _PlungerAnimationState.pull: pullAnimation, - }; - current = _PlungerAnimationState.release; - } -} - -/// {@template plunger_anchor} -/// [JointAnchor] positioned below a [Plunger]. -/// {@endtemplate} -class PlungerAnchor extends JointAnchor { - /// {@macro plunger_anchor} - PlungerAnchor({ - required Plunger plunger, - }) { - initialPosition = Vector2( - 0, - plunger.compressionDistance, - ); - } -} - -/// {@template plunger_anchor_prismatic_joint_def} -/// [PrismaticJointDef] between a [Plunger] and an [JointAnchor] with motion on -/// the vertical axis. -/// -/// The [Plunger] is constrained vertically between its starting position and -/// the [JointAnchor]. The [JointAnchor] must be below the [Plunger]. -/// {@endtemplate} -class PlungerAnchorPrismaticJointDef extends PrismaticJointDef { - /// {@macro plunger_anchor_prismatic_joint_def} - PlungerAnchorPrismaticJointDef({ - required Plunger plunger, - required PlungerAnchor anchor, - }) { - initialize( - plunger.body, - anchor.body, - plunger.body.position + anchor.body.position, - Vector2(16, BoardDimensions.bounds.height), - ); - enableLimit = true; - lowerTranslation = double.negativeInfinity; - enableMotor = true; - motorSpeed = 1000; - maxMotorForce = motorSpeed; - collideConnected = true; - } -} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/behaviors.dart new file mode 100644 index 00000000..0c772a0e --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/behaviors.dart @@ -0,0 +1,5 @@ +export 'plunger_jointing_behavior.dart'; +export 'plunger_key_controlling_behavior.dart'; +export 'plunger_noise_behavior.dart'; +export 'plunger_pulling_behavior.dart'; +export 'plunger_releasing_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_jointing_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_jointing_behavior.dart new file mode 100644 index 00000000..06332bef --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_jointing_behavior.dart @@ -0,0 +1,54 @@ +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class PlungerJointingBehavior extends Component with ParentIsA { + PlungerJointingBehavior({required double compressionDistance}) + : _compressionDistance = compressionDistance; + + final double _compressionDistance; + + @override + Future onLoad() async { + await super.onLoad(); + final anchor = JointAnchor() + ..initialPosition = Vector2(0, _compressionDistance); + await add(anchor); + + final jointDef = _PlungerAnchorPrismaticJointDef( + plunger: parent, + anchor: anchor, + ); + + parent.world.createJoint( + PrismaticJoint(jointDef)..setLimits(-_compressionDistance, 0), + ); + } +} + +/// [PrismaticJointDef] between a [Plunger] and an [JointAnchor] with motion on +/// the vertical axis. +/// +/// The [Plunger] is constrained vertically between its starting position and +/// the [JointAnchor]. The [JointAnchor] must be below the [Plunger]. +class _PlungerAnchorPrismaticJointDef extends PrismaticJointDef { + /// {@macro plunger_anchor_prismatic_joint_def} + _PlungerAnchorPrismaticJointDef({ + required Plunger plunger, + required BodyComponent anchor, + }) { + initialize( + plunger.body, + anchor.body, + plunger.body.position + anchor.body.position, + Vector2(16, BoardDimensions.bounds.height), + ); + enableLimit = true; + lowerTranslation = double.negativeInfinity; + enableMotor = true; + motorSpeed = 1000; + maxMotorForce = motorSpeed; + collideConnected = true; + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_key_controlling_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_key_controlling_behavior.dart new file mode 100644 index 00000000..fcff816a --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_key_controlling_behavior.dart @@ -0,0 +1,33 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flutter/services.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// Allows controlling the [Plunger]'s movement with keyboard input. +class PlungerKeyControllingBehavior extends Component + with KeyboardHandler, FlameBlocReader { + /// The [LogicalKeyboardKey]s that will control the [Plunger]. + /// + /// [onKeyEvent] method listens to when one of these keys is pressed. + static const List _keys = [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyS, + ]; + + @override + bool onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + if (!_keys.contains(event.logicalKey)) return true; + + if (event is RawKeyDownEvent) { + bloc.pulled(); + } else if (event is RawKeyUpEvent) { + bloc.released(); + } + + return false; + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_noise_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_noise_behavior.dart new file mode 100644 index 00000000..96cb9bd2 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_noise_behavior.dart @@ -0,0 +1,19 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Plays the [PinballAudio.launcher] sound. +/// +/// It is attached when the plunger is released. +class PlungerNoiseBehavior extends Component + with FlameBlocListenable { + @override + void onNewState(PlungerState state) { + super.onNewState(state); + if (state.isReleasing) { + readProvider().play(PinballAudio.launcher); + } + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_pulling_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_pulling_behavior.dart new file mode 100644 index 00000000..db6bcaa3 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_pulling_behavior.dart @@ -0,0 +1,46 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class PlungerPullingBehavior extends Component + with FlameBlocReader { + PlungerPullingBehavior({ + required double strength, + }) : assert(strength >= 0, "Strength can't be negative."), + _strength = strength; + + final double _strength; + + late final Plunger _plunger; + + @override + Future onLoad() async { + await super.onLoad(); + _plunger = parent!.parent! as Plunger; + } + + @override + void update(double dt) { + if (bloc.state.isPulling) { + _plunger.body.linearVelocity = Vector2(0, _strength); + } + } +} + +class PlungerAutoPullingBehavior extends PlungerPullingBehavior { + PlungerAutoPullingBehavior({ + required double strength, + }) : super(strength: strength); + + @override + void update(double dt) { + super.update(dt); + + final joint = _plunger.body.joints.whereType().single; + final reachedBottom = joint.getJointTranslation() <= joint.getLowerLimit(); + if (reachedBottom) { + bloc.released(); + } + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_releasing_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_releasing_behavior.dart new file mode 100644 index 00000000..d2935818 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_releasing_behavior.dart @@ -0,0 +1,31 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class PlungerReleasingBehavior extends Component + with FlameBlocListenable { + PlungerReleasingBehavior({ + required double strength, + }) : assert(strength >= 0, "Strength can't be negative."), + _strength = strength; + + final double _strength; + + late final Plunger _plunger; + + @override + Future onLoad() async { + await super.onLoad(); + _plunger = parent!.parent! as Plunger; + } + + @override + void onNewState(PlungerState state) { + super.onNewState(state); + if (state.isReleasing) { + final velocity = + (_plunger.initialPosition.y - _plunger.body.position.y) * _strength; + _plunger.body.linearVelocity = Vector2(0, velocity); + } + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/cubit/plunger_cubit.dart b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_cubit.dart new file mode 100644 index 00000000..601257d2 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_cubit.dart @@ -0,0 +1,15 @@ +import 'package:bloc/bloc.dart'; + +part 'plunger_state.dart'; + +class PlungerCubit extends Cubit { + PlungerCubit() : super(PlungerState.releasing); + + void pulled() { + emit(PlungerState.pulling); + } + + void released() { + emit(PlungerState.releasing); + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/cubit/plunger_state.dart b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_state.dart new file mode 100644 index 00000000..8b82ef96 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_state.dart @@ -0,0 +1,12 @@ +part of 'plunger_cubit.dart'; + +enum PlungerState { + pulling, + + releasing, +} + +extension PlungerStateX on PlungerState { + bool get isPulling => this == PlungerState.pulling; + bool get isReleasing => this == PlungerState.releasing; +} diff --git a/packages/pinball_components/lib/src/components/plunger/plunger.dart b/packages/pinball_components/lib/src/components/plunger/plunger.dart new file mode 100644 index 00000000..9f3b6873 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/plunger.dart @@ -0,0 +1,139 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'behaviors/behaviors.dart'; +export 'cubit/plunger_cubit.dart'; + +/// {@template plunger} +/// [Plunger] serves as a spring, that shoots the ball on the right side of the +/// play field. +/// +/// [Plunger] ignores gravity so the player controls its downward movement. +/// {@endtemplate} +class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex { + /// {@macro plunger} + Plunger() + : super( + renderBody: false, + children: [ + FlameBlocProvider( + create: PlungerCubit.new, + children: [ + _PlungerSpriteAnimationGroupComponent(), + PlungerReleasingBehavior(strength: 11), + PlungerNoiseBehavior(), + ], + ), + PlungerJointingBehavior(compressionDistance: 9.2), + ], + ) { + zIndex = ZIndexes.plunger; + layer = Layer.launcher; + } + + /// Creates a [Plunger] without any children. + /// + /// This can be used for testing [Plunger]'s behaviors in isolation. + @visibleForTesting + Plunger.test(); + + List _createFixtureDefs() { + final leftShapeVertices = [ + Vector2(0, 0), + Vector2(-1.8, 0), + Vector2(-1.8, -2.2), + Vector2(0, -0.3), + ]..forEach((vector) => vector.rotate(BoardDimensions.perspectiveAngle)); + final leftTriangleShape = PolygonShape()..set(leftShapeVertices); + + final rightShapeVertices = [ + Vector2(0, 0), + Vector2(1.8, 0), + Vector2(1.8, -2.2), + Vector2(0, -0.3), + ]..forEach((vector) => vector.rotate(BoardDimensions.perspectiveAngle)); + final rightTriangleShape = PolygonShape()..set(rightShapeVertices); + + return [ + FixtureDef( + leftTriangleShape, + density: 80, + ), + FixtureDef( + rightTriangleShape, + density: 80, + ), + ]; + } + + @override + Body createBody() { + final bodyDef = BodyDef( + position: initialPosition, + type: BodyType.dynamic, + gravityScale: Vector2.zero(), + ); + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + + return body; + } +} + +class _PlungerSpriteAnimationGroupComponent + extends SpriteAnimationGroupComponent + with HasGameRef, FlameBlocListenable { + _PlungerSpriteAnimationGroupComponent() + : super( + anchor: Anchor.center, + position: Vector2(1.87, 14.9), + ); + + @override + void onNewState(PlungerState state) { + super.onNewState(state); + final startedReleasing = state.isReleasing && !current!.isReleasing; + final startedPulling = state.isPulling && !current!.isPulling; + if (startedReleasing || startedPulling) { + animation?.reset(); + } + + current = state; + } + + @override + Future onLoad() async { + await super.onLoad(); + final spriteSheet = await gameRef.images.load( + Assets.images.plunger.plunger.keyName, + ); + const amountPerRow = 20; + const amountPerColumn = 1; + final textureSize = Vector2( + spriteSheet.width / amountPerRow, + spriteSheet.height / amountPerColumn, + ); + size = textureSize / 10; + final pullAnimation = SpriteAnimation.fromFrameData( + spriteSheet, + SpriteAnimationData.sequenced( + amount: amountPerRow * amountPerColumn ~/ 2, + amountPerRow: amountPerRow ~/ 2, + stepTime: 1 / 24, + textureSize: textureSize, + texturePosition: Vector2.zero(), + loop: false, + ), + ); + animations = { + PlungerState.releasing: pullAnimation.reversed(), + PlungerState.pulling: pullAnimation, + }; + + current = readBloc().state; + } +} diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index fe52f8b8..7fe3ac5e 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: flutter: sdk: flutter intl: ^0.17.0 + pinball_audio: + path: ../pinball_audio pinball_flame: path: ../pinball_flame pinball_theme: diff --git a/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart b/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart index 0ee58cc9..50af919f 100644 --- a/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart @@ -1,11 +1,10 @@ import 'package:flame/input.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/common/common.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; -class PlungerGame extends BallGame with KeyboardEvents, Traceable { +class PlungerGame extends BallGame + with HasKeyboardHandlerComponents, Traceable { static const description = ''' Shows how Plunger is rendered. @@ -13,39 +12,16 @@ class PlungerGame extends BallGame with KeyboardEvents, Traceable { - Tap anywhere on the screen to spawn a ball into the game. '''; - static const _downKeys = [ - LogicalKeyboardKey.arrowDown, - LogicalKeyboardKey.space, - ]; - - late Plunger plunger; - @override Future onLoad() async { await super.onLoad(); final center = screenToWorld(camera.viewport.canvasSize! / 2); - await add( - plunger = Plunger(compressionDistance: 29) - ..initialPosition = Vector2(center.x - 8.8, center.y), - ); - await traceAllBodies(); - } + final plunger = Plunger() + ..initialPosition = Vector2(center.x - 8.8, center.y); + await add(plunger); + await plunger.add(PlungerKeyControllingBehavior()); - @override - KeyEventResult onKeyEvent( - RawKeyEvent event, - Set keysPressed, - ) { - final movedPlungerDown = _downKeys.contains(event.logicalKey); - if (movedPlungerDown) { - if (event is RawKeyDownEvent) { - plunger.pull(); - } else if (event is RawKeyUpEvent) { - plunger.release(); - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; + await traceAllBodies(); } } diff --git a/packages/pinball_components/sandbox/pubspec.lock b/packages/pinball_components/sandbox/pubspec.lock index b5ac88b7..9fcb0f89 100644 --- a/packages/pinball_components/sandbox/pubspec.lock +++ b/packages/pinball_components/sandbox/pubspec.lock @@ -15,6 +15,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: transitive description: @@ -57,6 +64,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" dashbook: dependency: "direct main" description: @@ -106,6 +120,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + flame_audio: + dependency: transitive + description: + name: flame_audio + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" flame_bloc: dependency: transitive description: @@ -179,6 +200,20 @@ packages: relative: true source: path version: "1.0.0+1" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" intl: dependency: transitive description: @@ -249,6 +284,27 @@ 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.13" + 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: @@ -256,6 +312,13 @@ packages: 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: @@ -270,6 +333,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.5" + pinball_audio: + dependency: transitive + description: + path: "../../pinball_audio" + relative: true + source: path + version: "1.0.0+1" pinball_components: dependency: "direct main" description: @@ -415,6 +485,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: @@ -492,6 +569,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.0" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" vector_math: dependency: transitive description: diff --git a/packages/pinball_components/test/src/components/bumping_behavior_test.dart b/packages/pinball_components/test/src/components/bumping_behavior_test.dart index 07e35cca..7a87a46c 100644 --- a/packages/pinball_components/test/src/components/bumping_behavior_test.dart +++ b/packages/pinball_components/test/src/components/bumping_behavior_test.dart @@ -24,6 +24,20 @@ void main() { final flameTester = FlameTester(TestGame.new); group('BumpingBehavior', () { + test('can be instantiated', () { + expect( + BumpingBehavior(strength: 0), + isA(), + ); + }); + + test('throws assertion error when strength is negative ', () { + expect( + () => BumpingBehavior(strength: -1), + throwsAssertionError, + ); + }); + flameTester.test('can be added', (game) async { final behavior = BumpingBehavior(strength: 0); final component = _TestBodyComponent(); diff --git a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart index 3d6c3b83..70c93439 100644 --- a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart +++ b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart @@ -19,19 +19,18 @@ void main() { }); flameTester.test('can be loaded', (game) async { - final behavior = FlipperJointingBehavior(); final parent = Flipper.test(side: BoardSide.left); + final behavior = FlipperJointingBehavior(); await game.ensureAdd(parent); await parent.ensureAdd(behavior); expect(parent.contains(behavior), isTrue); }); flameTester.test('creates a joint', (game) async { - final behavior = FlipperJointingBehavior(); final parent = Flipper.test(side: BoardSide.left); + final behavior = FlipperJointingBehavior(); await game.ensureAdd(parent); await parent.ensureAdd(behavior); - expect(parent.body.joints, isNotEmpty); }); }); diff --git a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart index 11af6187..6a6ac91c 100644 --- a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart +++ b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart @@ -1,5 +1,6 @@ // ignore_for_file: cascade_invocations +import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -8,8 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball_components/pinball_components.dart'; -import '../../../../helpers/helpers.dart'; - class _MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { @@ -27,7 +26,7 @@ class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('FlipperKeyControllingBehavior', () { - final flameTester = FlameTester(TestGame.new); + final flameTester = FlameTester(Forge2DGame.new); group( 'onKeyEvent', diff --git a/packages/pinball_components/test/src/components/golden/plunger/pull.png b/packages/pinball_components/test/src/components/golden/plunger/pull.png index cdbb3e31..3a3e204f 100644 Binary files a/packages/pinball_components/test/src/components/golden/plunger/pull.png and b/packages/pinball_components/test/src/components/golden/plunger/pull.png differ diff --git a/packages/pinball_components/test/src/components/golden/plunger/release.png b/packages/pinball_components/test/src/components/golden/plunger/release.png index cda853c3..2aae1a50 100644 Binary files a/packages/pinball_components/test/src/components/golden/plunger/release.png and b/packages/pinball_components/test/src/components/golden/plunger/release.png differ diff --git a/packages/pinball_components/test/src/components/google_word/behaviors/google_word_animating_behavior_test.dart b/packages/pinball_components/test/src/components/google_word/behaviors/google_word_animating_behavior_test.dart new file mode 100644 index 00000000..6275678c --- /dev/null +++ b/packages/pinball_components/test/src/components/google_word/behaviors/google_word_animating_behavior_test.dart @@ -0,0 +1,64 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + GoogleWordAnimatingBehavior child, { + required GoogleWordCubit bloc, + }) async { + await ensureAdd( + FlameBlocProvider.value( + value: bloc, + children: [child], + ), + ); + } +} + +class _MockGoogleWordCubit extends Mock implements GoogleWordCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + group('GoogleWordAnimatingBehavior', () { + flameTester.testGameWidget( + 'calls switched after timer period reached', + setUp: (game, tester) async { + final behavior = GoogleWordAnimatingBehavior(); + final bloc = _MockGoogleWordCubit(); + await game.pump(behavior, bloc: bloc); + game.update(behavior.timer.limit); + + verify(bloc.switched).called(1); + }, + ); + + flameTester.testGameWidget( + 'calls onReset and removes itself ' + 'after all blinks complete', + setUp: (game, tester) async { + final behavior = GoogleWordAnimatingBehavior(); + final bloc = _MockGoogleWordCubit(); + + await game.pump(behavior, bloc: bloc); + for (var i = 0; i <= 14; i++) { + game.update(behavior.timer.limit); + } + await game.ready(); + + verify(bloc.onReset).called(1); + expect( + game.descendants().whereType().isEmpty, + isTrue, + ); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart b/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart index d315b7c4..152b5f96 100644 --- a/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart +++ b/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart @@ -6,6 +6,21 @@ void main() { group( 'GoogleWordCubit', () { + final litEvens = { + for (int i = 0; i < 6; i++) + if (i.isEven) + i: GoogleLetterSpriteState.lit + else + i: GoogleLetterSpriteState.dimmed + }; + final litOdds = { + for (int i = 0; i < 6; i++) + if (i.isOdd) + i: GoogleLetterSpriteState.lit + else + i: GoogleLetterSpriteState.dimmed + }; + blocTest( 'onRolloverContacted emits first letter lit', build: GoogleWordCubit.new, @@ -25,7 +40,29 @@ void main() { ); blocTest( - 'onBonusAwarded emits initial state', + 'switched emits all even letters lit when first letter is dimmed', + build: GoogleWordCubit.new, + act: (bloc) => bloc.switched(), + expect: () => [GoogleWordState(letterSpriteStates: litEvens)], + ); + + blocTest( + 'switched emits all odd letters lit when first letter is lit', + build: GoogleWordCubit.new, + seed: () => GoogleWordState(letterSpriteStates: litEvens), + act: (bloc) => bloc.switched(), + expect: () => [GoogleWordState(letterSpriteStates: litOdds)], + ); + + blocTest( + 'onBonusAwarded emits all even letters lit', + build: GoogleWordCubit.new, + act: (bloc) => bloc.onBonusAwarded(), + expect: () => [GoogleWordState(letterSpriteStates: litEvens)], + ); + + blocTest( + 'onReset emits initial state', build: GoogleWordCubit.new, act: (bloc) => bloc.onReset(), expect: () => [GoogleWordState.initial()], diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_jointing_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_jointing_behavior_test.dart new file mode 100644 index 00000000..940ea625 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_jointing_behavior_test.dart @@ -0,0 +1,36 @@ +// 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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(Forge2DGame.new); + + group('PlungerJointingBehavior', () { + test('can be instantiated', () { + expect( + PlungerJointingBehavior(compressionDistance: 0), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final parent = Plunger.test(); + final behavior = PlungerJointingBehavior(compressionDistance: 0); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + expect(parent.children, contains(behavior)); + }); + + flameTester.test('creates a joint', (game) async { + final behavior = PlungerJointingBehavior(compressionDistance: 0); + final parent = Plunger.test(); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + expect(parent.body.joints, isNotEmpty); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_key_controlling_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_key_controlling_behavior_test.dart new file mode 100644 index 00000000..1147d7f3 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_key_controlling_behavior_test.dart @@ -0,0 +1,194 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + PlungerKeyControllingBehavior child, { + PlungerCubit? plungerBloc, + }) async { + final plunger = Plunger.test(); + await ensureAdd(plunger); + return plunger.ensureAdd( + FlameBlocProvider.value( + value: plungerBloc ?? _MockPlungerCubit(), + children: [child], + ), + ); + } +} + +class _MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} + +class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + group('PlungerKeyControllingBehavior', () { + test('can be instantiated', () { + expect( + PlungerKeyControllingBehavior(), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + group('onKeyEvent', () { + late PlungerCubit plungerBloc; + + setUp(() { + plungerBloc = _MockPlungerCubit(); + }); + + group('pulls when', () { + flameTester.test( + 'down arrow is pressed', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyDownEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.arrowDown, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.pulled()).called(1); + }, + ); + + flameTester.test( + '"s" is pressed', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyDownEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.keyS, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.pulled()).called(1); + }, + ); + + flameTester.test( + 'space is pressed', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyDownEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.space, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.pulled()).called(1); + }, + ); + }); + + group('releases when', () { + flameTester.test( + 'down arrow is released', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.arrowDown, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.released()).called(1); + }, + ); + + flameTester.test( + '"s" is released', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.keyS, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.released()).called(1); + }, + ); + + flameTester.test( + 'space is released', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.space, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.released()).called(1); + }, + ); + }); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_noise_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_noise_behavior_test.dart new file mode 100644 index 00000000..a5e11ad0 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_noise_behavior_test.dart @@ -0,0 +1,91 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + Component child, { + PinballAudioPlayer? pinballAudioPlayer, + PlungerCubit? plungerBloc, + }) async { + final parent = Component(); + await ensureAdd(parent); + return parent.ensureAdd( + FlameProvider.value( + pinballAudioPlayer ?? _MockPinballAudioPlayer(), + children: [ + FlameBlocProvider.value( + value: plungerBloc ?? PlungerCubit(), + children: [child], + ), + ], + ), + ); + } +} + +class _MockPinballAudioPlayer extends Mock implements PinballAudioPlayer {} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + group('PlungerNoiseBehavior', () { + late PinballAudioPlayer audioPlayer; + + setUp(() { + audioPlayer = _MockPinballAudioPlayer(); + }); + + test('can be instantiated', () { + expect( + PlungerNoiseBehavior(), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerNoiseBehavior(); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test( + 'plays the correct sound when released', + (game) async { + final plungerBloc = _MockPlungerCubit(); + final streamController = StreamController(); + whenListen( + plungerBloc, + streamController.stream, + initialState: PlungerState.pulling, + ); + + final behavior = PlungerNoiseBehavior(); + await game.pump( + behavior, + pinballAudioPlayer: audioPlayer, + plungerBloc: plungerBloc, + ); + + streamController.add(PlungerState.releasing); + await Future.delayed(Duration.zero); + + verify(() => audioPlayer.play(PinballAudio.launcher)).called(1); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_pulling_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_pulling_behavior_test.dart new file mode 100644 index 00000000..4eec7029 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_pulling_behavior_test.dart @@ -0,0 +1,160 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + PlungerPullingBehavior behavior, { + PlungerCubit? plungerBloc, + }) async { + final plunger = Plunger.test(); + await ensureAdd(plunger); + return plunger.ensureAdd( + FlameBlocProvider.value( + value: plungerBloc ?? _MockPlungerCubit(), + children: [behavior], + ), + ); + } +} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +class _MockPrismaticJoint extends Mock implements PrismaticJoint {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + group('PlungerPullingBehavior', () { + test('can be instantiated', () { + expect( + PlungerPullingBehavior(strength: 0), + isA(), + ); + }); + + test('throws assertion error when strength is negative ', () { + expect( + () => PlungerPullingBehavior(strength: -1), + throwsAssertionError, + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerPullingBehavior(strength: 0); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test( + 'applies vertical linear velocity when pulled', + (game) async { + final plungerBloc = _MockPlungerCubit(); + whenListen( + plungerBloc, + Stream.value(PlungerState.pulling), + initialState: PlungerState.pulling, + ); + + const strength = 2.0; + final behavior = PlungerPullingBehavior( + strength: strength, + ); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + game.update(0); + + final plunger = behavior.ancestors().whereType().single; + expect(plunger.body.linearVelocity.x, equals(0)); + expect(plunger.body.linearVelocity.y, equals(strength)); + }, + ); + }); + + group('PlungerAutoPullingBehavior', () { + test('can be instantiated', () { + expect( + PlungerAutoPullingBehavior(strength: 0), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerAutoPullingBehavior(strength: 0); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test( + "pulls while joint hasn't reached limit", + (game) async { + final plungerBloc = _MockPlungerCubit(); + whenListen( + plungerBloc, + Stream.value(PlungerState.pulling), + initialState: PlungerState.pulling, + ); + + const strength = 2.0; + final behavior = PlungerAutoPullingBehavior( + strength: strength, + ); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + final plunger = behavior.ancestors().whereType().single; + final joint = _MockPrismaticJoint(); + when(joint.getJointTranslation).thenReturn(2); + when(joint.getLowerLimit).thenReturn(0); + plunger.body.joints.add(joint); + + game.update(0); + + expect(plunger.body.linearVelocity.x, equals(0)); + expect(plunger.body.linearVelocity.y, equals(strength)); + }, + ); + + flameTester.test( + 'releases when joint reaches limit', + (game) async { + final plungerBloc = _MockPlungerCubit(); + whenListen( + plungerBloc, + Stream.value(PlungerState.pulling), + initialState: PlungerState.pulling, + ); + + const strength = 2.0; + final behavior = PlungerAutoPullingBehavior( + strength: strength, + ); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + final plunger = behavior.ancestors().whereType().single; + final joint = _MockPrismaticJoint(); + when(joint.getJointTranslation).thenReturn(0); + when(joint.getLowerLimit).thenReturn(0); + plunger.body.joints.add(joint); + + game.update(0); + + verify(plungerBloc.released).called(1); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_releasing_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_releasing_behavior_test.dart new file mode 100644 index 00000000..501753c4 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_releasing_behavior_test.dart @@ -0,0 +1,79 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/forge2d_game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + PlungerReleasingBehavior behavior, { + PlungerCubit? plungerBloc, + }) async { + final plunger = Plunger.test(); + await ensureAdd(plunger); + return plunger.ensureAdd( + FlameBlocProvider.value( + value: plungerBloc ?? PlungerCubit(), + children: [behavior], + ), + ); + } +} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +void main() { + group('PlungerReleasingBehavior', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + test('can be instantiated', () { + expect( + PlungerReleasingBehavior(strength: 0), + isA(), + ); + }); + + test('throws assertion error when strength is negative ', () { + expect( + () => PlungerReleasingBehavior(strength: -1), + throwsAssertionError, + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerReleasingBehavior(strength: 0); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test('applies vertical linear velocity', (game) async { + final plungerBloc = _MockPlungerCubit(); + final streamController = StreamController(); + whenListen( + plungerBloc, + streamController.stream, + initialState: PlungerState.pulling, + ); + + final behavior = PlungerReleasingBehavior(strength: 2); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + streamController.add(PlungerState.releasing); + await Future.delayed(Duration.zero); + + final plunger = behavior.ancestors().whereType().single; + expect(plunger.body.linearVelocity.x, equals(0)); + expect(plunger.body.linearVelocity.y, isNot(greaterThan(0))); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/plunger_test.dart b/packages/pinball_components/test/src/components/plunger/plunger_test.dart new file mode 100644 index 00000000..32a6a45b --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/plunger_test.dart @@ -0,0 +1,116 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_bloc/flame_bloc.dart'; +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'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group('Plunger', () { + test('can be instantiated', () { + expect(Plunger(), isA()); + }); + + flameTester.test( + 'loads correctly', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect(game.children, contains(plunger)); + }, + ); + + group('adds', () { + flameTester.test( + 'a PlungerReleasingBehavior', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'a PlungerJointingBehavior', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'a PlungerNoiseBehavior', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + }); + + group('renders correctly', () { + const goldenPath = '../golden/plunger/'; + flameTester.testGameWidget( + 'pulling', + setUp: (game, tester) async { + await game.ensureAdd(Plunger()); + game.camera.followVector2(Vector2.zero()); + game.camera.zoom = 4.1; + }, + verify: (game, tester) async { + final plunger = game.descendants().whereType().first; + final bloc = plunger + .descendants() + .whereType>() + .single + .bloc; + bloc.pulled(); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('${goldenPath}pull.png'), + ); + }, + ); + + flameTester.testGameWidget( + 'releasing', + setUp: (game, tester) async { + await game.ensureAdd(Plunger()); + game.camera.followVector2(Vector2.zero()); + game.camera.zoom = 4.1; + }, + verify: (game, tester) async { + final plunger = game.descendants().whereType().first; + final bloc = plunger + .descendants() + .whereType>() + .single + .bloc; + bloc.released(); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('${goldenPath}release.png'), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger_test.dart b/packages/pinball_components/test/src/components/plunger_test.dart deleted file mode 100644 index e28bdaed..00000000 --- a/packages/pinball_components/test/src/components/plunger_test.dart +++ /dev/null @@ -1,391 +0,0 @@ -// 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'; - -import '../../helpers/helpers.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(TestGame.new); - - group('Plunger', () { - const compressionDistance = 0.0; - - test('can be instantiated', () { - expect( - Plunger(compressionDistance: compressionDistance), - isA(), - ); - expect( - Plunger.test(compressionDistance: compressionDistance), - isA(), - ); - }); - - flameTester.testGameWidget( - 'renders correctly', - setUp: (game, tester) async { - await game.ensureAdd(Plunger(compressionDistance: compressionDistance)); - - game.camera.followVector2(Vector2.zero()); - game.camera.zoom = 4.1; - }, - verify: (game, tester) async { - final plunger = game.descendants().whereType().first; - plunger.pull(); - game.update(1); - await tester.pump(); - await expectLater( - find.byGame(), - matchesGoldenFile('golden/plunger/pull.png'), - ); - - plunger.release(); - game.update(1); - await tester.pump(); - await expectLater( - find.byGame(), - matchesGoldenFile('golden/plunger/release.png'), - ); - }, - ); - - flameTester.test( - 'loads correctly', - (game) async { - await game.ready(); - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(game.contains(plunger), isTrue); - }, - ); - - group('body', () { - flameTester.test( - 'is dynamic', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(plunger.body.bodyType, equals(BodyType.dynamic)); - }, - ); - - flameTester.test( - 'ignores gravity', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(plunger.body.gravityScale, equals(Vector2.zero())); - }, - ); - }); - - group('fixture', () { - flameTester.test( - 'exists', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(plunger.body.fixtures[0], isA()); - }, - ); - - flameTester.test( - 'shape is a polygon', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - final fixture = plunger.body.fixtures[0]; - expect(fixture.shape.shapeType, equals(ShapeType.polygon)); - }, - ); - - flameTester.test( - 'has density', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - final fixture = plunger.body.fixtures[0]; - expect(fixture.density, greaterThan(0)); - }, - ); - }); - - group('pullFor', () { - late Plunger plunger; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - }); - - flameTester.testGameWidget( - 'moves downwards for given period when pullFor is called', - setUp: (game, tester) async { - await game.ensureAdd(plunger); - }, - verify: (game, tester) async { - plunger.pullFor(2); - game.update(0); - - expect(plunger.body.linearVelocity.y, isPositive); - - // Call game update at 120 FPS, so that the plunger will act as if it - // was pulled for 2 seconds. - for (var i = 0.0; i < 2; i += 1 / 120) { - game.update(1 / 20); - } - - expect(plunger.body.linearVelocity.y, isZero); - }, - ); - }); - - group('pull', () { - late Plunger plunger; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - }); - - flameTester.test( - 'moves downwards when pull is called', - (game) async { - await game.ensureAdd(plunger); - plunger.pull(); - - expect(plunger.body.linearVelocity.y, isPositive); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - - flameTester.test( - 'moves downwards when pull is called ' - 'and plunger is below its starting position', (game) async { - await game.ensureAdd(plunger); - plunger.pull(); - plunger.release(); - plunger.pull(); - - expect(plunger.body.linearVelocity.y, isPositive); - expect(plunger.body.linearVelocity.x, isZero); - }); - }); - - group('release', () { - late Plunger plunger; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - }); - - flameTester.test( - 'moves upwards when release is called ' - 'and plunger is below its starting position', (game) async { - await game.ensureAdd(plunger); - plunger.body.setTransform(Vector2(0, 1), 0); - plunger.release(); - - expect(plunger.body.linearVelocity.y, isNegative); - expect(plunger.body.linearVelocity.x, isZero); - }); - - flameTester.test( - 'does not move when release is called ' - 'and plunger is in its starting position', - (game) async { - await game.ensureAdd(plunger); - plunger.release(); - - expect(plunger.body.linearVelocity.y, isZero); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - }); - - group('PlungerAnchor', () { - const compressionDistance = 10.0; - - flameTester.test( - 'position is a compression distance below the Plunger', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - final plungerAnchor = PlungerAnchor(plunger: plunger); - await game.ensureAdd(plungerAnchor); - - expect( - plungerAnchor.body.position.y, - equals(plunger.body.position.y + compressionDistance), - ); - }, - ); - }); - - group('PlungerAnchorPrismaticJointDef', () { - const compressionDistance = 10.0; - late Plunger plunger; - late PlungerAnchor anchor; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - anchor = PlungerAnchor(plunger: plunger); - }); - - group('initializes with', () { - flameTester.test( - 'plunger body as bodyA', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - - expect(jointDef.bodyA, equals(plunger.body)); - }, - ); - - flameTester.test( - 'anchor body as bodyB', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.bodyB, equals(anchor.body)); - }, - ); - - flameTester.test( - 'limits enabled', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.enableLimit, isTrue); - }, - ); - - flameTester.test( - 'lower translation limit as negative infinity', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.lowerTranslation, equals(double.negativeInfinity)); - }, - ); - - flameTester.test( - 'connected body collision enabled', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.collideConnected, isTrue); - }, - ); - }); - - flameTester.testGameWidget( - 'plunger cannot go below anchor', - setUp: (game, tester) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - // Giving anchor a shape for the plunger to collide with. - anchor.body.createFixtureFromShape(PolygonShape()..setAsBoxXY(2, 1)); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - await tester.pump(const Duration(seconds: 1)); - }, - verify: (game, tester) async { - expect(plunger.body.position.y < anchor.body.position.y, isTrue); - }, - ); - - flameTester.testGameWidget( - 'plunger cannot excessively exceed starting position', - setUp: (game, tester) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - plunger.body.setTransform(Vector2(0, -1), 0); - - await tester.pump(const Duration(seconds: 1)); - }, - verify: (game, tester) async { - expect(plunger.body.position.y < 1, isTrue); - }, - ); - }); -} diff --git a/packages/pinball_flame/lib/pinball_flame.dart b/packages/pinball_flame/lib/pinball_flame.dart index c40405cb..410d3151 100644 --- a/packages/pinball_flame/lib/pinball_flame.dart +++ b/packages/pinball_flame/lib/pinball_flame.dart @@ -2,7 +2,6 @@ library pinball_flame; export 'src/behaviors/behaviors.dart'; export 'src/canvas/canvas.dart'; -export 'src/component_controller.dart'; export 'src/flame_provider.dart'; export 'src/keyboard_input_controller.dart'; export 'src/layer.dart'; diff --git a/packages/pinball_flame/lib/src/component_controller.dart b/packages/pinball_flame/lib/src/component_controller.dart deleted file mode 100644 index 6afc1c40..00000000 --- a/packages/pinball_flame/lib/src/component_controller.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flutter/foundation.dart'; - -/// {@template component_controller} -/// A [ComponentController] is a [Component] in charge of handling the logic -/// associated with another [Component]. -/// {@endtemplate} -abstract class ComponentController extends Component { - /// {@macro component_controller} - ComponentController(this.component); - - /// The [Component] controlled by this [ComponentController]. - final T component; - - @override - Future addToParent(Component parent) async { - assert( - parent == component, - 'ComponentController should be child of $component.', - ); - await super.addToParent(parent); - } - - @override - Future add(Component component) { - throw Exception('ComponentController cannot add other components.'); - } -} - -/// Mixin that attaches a single [ComponentController] to a [Component]. -mixin Controls on Component { - /// The [ComponentController] attached to this [Component]. - late T controller; - - @override - @mustCallSuper - Future onLoad() async { - await super.onLoad(); - await add(controller); - } -} diff --git a/packages/pinball_flame/test/src/component_controller_test.dart b/packages/pinball_flame/test/src/component_controller_test.dart deleted file mode 100644 index addcf2b0..00000000 --- a/packages/pinball_flame/test/src/component_controller_test.dart +++ /dev/null @@ -1,96 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame/game.dart'; -import 'package:flame/src/components/component.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -class TestComponentController extends ComponentController { - TestComponentController(Component component) : super(component); -} - -class ControlledComponent extends Component - with Controls { - ControlledComponent() : super() { - controller = TestComponentController(this); - } -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(FlameGame.new); - - group('ComponentController', () { - flameTester.test( - 'can be instantiated', - (game) async { - expect( - TestComponentController(Component()), - isA(), - ); - }, - ); - - flameTester.test( - 'throws AssertionError when not attached to controlled component', - (game) async { - final component = Component(); - final controller = TestComponentController(component); - - final anotherComponent = Component(); - await expectLater( - () async => await anotherComponent.add(controller), - throwsAssertionError, - ); - }, - ); - - flameTester.test( - 'throws Exception when adding a component', - (game) async { - final component = ControlledComponent(); - final controller = TestComponentController(component); - - await expectLater( - () async => controller.add(Component()), - throwsException, - ); - }, - ); - - flameTester.test( - 'throws Exception when adding multiple components', - (game) async { - final component = ControlledComponent(); - final controller = TestComponentController(component); - - await expectLater( - () async => controller.addAll([ - Component(), - Component(), - ]), - throwsException, - ); - }, - ); - }); - - group('Controls', () { - flameTester.test( - 'can be instantiated', - (game) async { - expect(ControlledComponent(), isA()); - }, - ); - - flameTester.test('adds controller', (game) async { - final component = ControlledComponent(); - - await game.add(component); - await game.ready(); - - expect(component.contains(component.controller), isTrue); - }); - }); -} diff --git a/packages/pinball_theme/assets/images/android/background.jpg b/packages/pinball_theme/assets/images/android/background.jpg index afe2e3c6..f7326a48 100644 Binary files a/packages/pinball_theme/assets/images/android/background.jpg and b/packages/pinball_theme/assets/images/android/background.jpg differ diff --git a/packages/pinball_theme/assets/images/dash/background.jpg b/packages/pinball_theme/assets/images/dash/background.jpg index 0b70a795..c9e9223d 100644 Binary files a/packages/pinball_theme/assets/images/dash/background.jpg and b/packages/pinball_theme/assets/images/dash/background.jpg differ diff --git a/packages/pinball_theme/assets/images/dino/background.jpg b/packages/pinball_theme/assets/images/dino/background.jpg index 35deff95..8c24a4c8 100644 Binary files a/packages/pinball_theme/assets/images/dino/background.jpg and b/packages/pinball_theme/assets/images/dino/background.jpg differ diff --git a/packages/pinball_theme/assets/images/sparky/background.jpg b/packages/pinball_theme/assets/images/sparky/background.jpg index 9c4fdfc6..aa0ac9a4 100644 Binary files a/packages/pinball_theme/assets/images/sparky/background.jpg and b/packages/pinball_theme/assets/images/sparky/background.jpg differ diff --git a/packages/pinball_ui/lib/src/widgets/pinball_button.dart b/packages/pinball_ui/lib/src/widgets/pinball_button.dart index dd4685c1..ee9b5c54 100644 --- a/packages/pinball_ui/lib/src/widgets/pinball_button.dart +++ b/packages/pinball_ui/lib/src/widgets/pinball_button.dart @@ -30,7 +30,7 @@ class PinballButton extends StatelessWidget { ), ), child: Center( - child: InkWell( + child: GestureDetector( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric( diff --git a/packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart b/packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart index a0c3e653..c122f5b3 100644 --- a/packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart +++ b/packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart @@ -53,7 +53,7 @@ class PinballDpadButton extends StatelessWidget { Widget build(BuildContext context) { return Material( color: PinballColors.transparent, - child: InkWell( + child: GestureDetector( onTap: onTap, child: Image.asset( direction.toAsset(), diff --git a/test/game/behaviors/ball_spawning_behavior_test.dart b/test/game/behaviors/ball_spawning_behavior_test.dart index d723c65e..dc272571 100644 --- a/test/game/behaviors/ball_spawning_behavior_test.dart +++ b/test/game/behaviors/ball_spawning_behavior_test.dart @@ -130,7 +130,7 @@ void main() { await game.pump([ behavior, ZCanvasComponent(), - Plunger.test(compressionDistance: 10), + Plunger.test(), ]); expect(game.descendants().whereType(), isEmpty); diff --git a/test/game/behaviors/character_selection_behavior_test.dart b/test/game/behaviors/character_selection_behavior_test.dart index edf17999..acf140a2 100644 --- a/test/game/behaviors/character_selection_behavior_test.dart +++ b/test/game/behaviors/character_selection_behavior_test.dart @@ -77,45 +77,8 @@ void main() { ); flameTester.test( - 'onNewState does not call onCharacterSelected on the arcade background ' - 'bloc when platform is mobile', + 'onNewState calls onCharacterSelected on the arcade background bloc', (game) async { - final platformHelper = _MockPlatformHelper(); - when(() => platformHelper.isMobile).thenAnswer((_) => true); - final arcadeBackgroundBloc = _MockArcadeBackgroundCubit(); - whenListen( - arcadeBackgroundBloc, - const Stream.empty(), - initialState: const ArcadeBackgroundState.initial(), - ); - final behavior = CharacterSelectionBehavior(); - await game.pump( - [ - behavior, - ZCanvasComponent(), - Plunger.test(compressionDistance: 10), - Ball.test(), - ], - platformHelper: platformHelper, - ); - - const dinoThemeState = CharacterThemeState(theme.DinoTheme()); - behavior.onNewState(dinoThemeState); - await game.ready(); - - verifyNever( - () => arcadeBackgroundBloc - .onCharacterSelected(dinoThemeState.characterTheme), - ); - }, - ); - - flameTester.test( - 'onNewState calls onCharacterSelected on the arcade background ' - 'bloc when platform is not mobile', - (game) async { - final platformHelper = _MockPlatformHelper(); - when(() => platformHelper.isMobile).thenAnswer((_) => false); final arcadeBackgroundBloc = _MockArcadeBackgroundCubit(); whenListen( arcadeBackgroundBloc, @@ -130,10 +93,9 @@ void main() { arcadeBackground, behavior, ZCanvasComponent(), - Plunger.test(compressionDistance: 10), + Plunger.test(), Ball.test(), ], - platformHelper: platformHelper, ); const dinoThemeState = CharacterThemeState(theme.DinoTheme()); @@ -165,7 +127,7 @@ void main() { ball, behavior, ZCanvasComponent(), - Plunger.test(compressionDistance: 10), + Plunger.test(), ArcadeBackground.test(), ], platformHelper: platformHelper, diff --git a/test/game/behaviors/rollover_noise_behavior_test.dart b/test/game/behaviors/rollover_noise_behavior_test.dart new file mode 100644 index 00000000..a196c8b6 --- /dev/null +++ b/test/game/behaviors/rollover_noise_behavior_test.dart @@ -0,0 +1,58 @@ +// 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:mocktail/mocktail.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + _TestBodyComponent child, { + required PinballAudioPlayer audioPlayer, + }) { + return ensureAdd( + FlameProvider.value( + audioPlayer, + children: [child], + ), + ); + } +} + +class _TestBodyComponent extends BodyComponent { + @override + Body createBody() => world.createBody(BodyDef()); +} + +class _MockPinballAudioPlayer extends Mock implements PinballAudioPlayer {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('RolloverNoiseBehavior', () { + late PinballAudioPlayer audioPlayer; + final flameTester = FlameTester(_TestGame.new); + + setUp(() { + audioPlayer = _MockPinballAudioPlayer(); + }); + flameTester.testGameWidget( + 'plays rollover sound on contact', + setUp: (game, _) async { + final behavior = RolloverNoiseBehavior(); + final parent = _TestBodyComponent(); + await game.pump(parent, audioPlayer: audioPlayer); + await parent.ensureAdd(behavior); + behavior.beginContact(Object(), _MockContact()); + }, + verify: (_, __) async { + verify(() => audioPlayer.play(PinballAudio.rollover)).called(1); + }, + ); + }); +} diff --git a/test/game/components/controlled_plunger_test.dart b/test/game/components/controlled_plunger_test.dart deleted file mode 100644 index 68bde767..00000000 --- a/test/game/components/controlled_plunger_test.dart +++ /dev/null @@ -1,185 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'dart:collection'; - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flame/components.dart'; -import 'package:flame/input.dart'; -import 'package:flame_bloc/flame_bloc.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/services.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 'package:pinball_flame/pinball_flame.dart'; - -import '../../helpers/helpers.dart'; - -class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents { - @override - Future onLoad() async { - images.prefix = ''; - await images.load(Assets.images.plunger.plunger.keyName); - } - - Future pump( - Plunger child, { - GameBloc? gameBloc, - PinballAudioPlayer? pinballAudioPlayer, - }) { - return ensureAdd( - FlameBlocProvider.value( - value: gameBloc ?? GameBloc() - ..add(const GameStarted()), - children: [ - FlameProvider.value( - pinballAudioPlayer ?? _MockPinballAudioPlayer(), - children: [child], - ) - ], - ), - ); - } -} - -class _MockGameBloc extends Mock implements GameBloc {} - -class _MockPinballAudioPlayer extends Mock implements PinballAudioPlayer {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(_TestGame.new); - - group('PlungerController', () { - late GameBloc gameBloc; - - final flameBlocTester = FlameTester(_TestGame.new); - - late Plunger plunger; - late PlungerController controller; - - setUp(() { - gameBloc = _MockGameBloc(); - plunger = ControlledPlunger(compressionDistance: 10); - controller = PlungerController(plunger); - plunger.add(controller); - }); - - group('onKeyEvent', () { - final downKeys = UnmodifiableListView([ - LogicalKeyboardKey.arrowDown, - LogicalKeyboardKey.space, - LogicalKeyboardKey.keyS, - ]); - - testRawKeyDownEvents(downKeys, (event) { - flameTester.test( - 'moves down ' - 'when ${event.logicalKey.keyLabel} is pressed', - (game) async { - await game.pump(plunger); - controller.onKeyEvent(event, {}); - - expect(plunger.body.linearVelocity.y, isPositive); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - - testRawKeyUpEvents(downKeys, (event) { - flameTester.test( - 'moves up ' - 'when ${event.logicalKey.keyLabel} is released ' - 'and plunger is below its starting position', - (game) async { - await game.pump(plunger); - plunger.body.setTransform(Vector2(0, 1), 0); - controller.onKeyEvent(event, {}); - - expect(plunger.body.linearVelocity.y, isNegative); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - - testRawKeyUpEvents(downKeys, (event) { - flameTester.test( - 'does not move when ${event.logicalKey.keyLabel} is released ' - 'and plunger is in its starting position', - (game) async { - await game.pump(plunger); - controller.onKeyEvent(event, {}); - - expect(plunger.body.linearVelocity.y, isZero); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - - testRawKeyDownEvents(downKeys, (event) { - flameBlocTester.testGameWidget( - 'does nothing when is game over', - setUp: (game, tester) async { - whenListen( - gameBloc, - const Stream.empty(), - initialState: const GameState.initial().copyWith( - status: GameStatus.gameOver, - ), - ); - - await game.pump(plunger, gameBloc: gameBloc); - controller.onKeyEvent(event, {}); - }, - verify: (game, tester) async { - expect(plunger.body.linearVelocity.y, isZero); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - }); - - flameTester.test( - 'adds the PlungerNoiseBehavior plunger is released', - (game) async { - await game.pump(plunger); - plunger.body.setTransform(Vector2(0, 1), 0); - plunger.release(); - - await game.ready(); - final count = - game.descendants().whereType().length; - expect(count, equals(1)); - }, - ); - }); - - group('PlungerNoiseBehavior', () { - late PinballAudioPlayer audioPlayer; - - setUp(() { - audioPlayer = _MockPinballAudioPlayer(); - }); - - flameTester.test('plays the correct sound on load', (game) async { - final parent = ControlledPlunger(compressionDistance: 10); - await game.pump(parent, pinballAudioPlayer: audioPlayer); - await parent.ensureAdd(PlungerNoiseBehavior()); - verify(() => audioPlayer.play(PinballAudio.launcher)).called(1); - }); - - test('is removed on the first update', () { - final parent = Component(); - final behavior = PlungerNoiseBehavior(); - parent.add(behavior); - parent.update(0); // Run a tick to ensure it is added - - behavior.update(0); // Run its own update where the removal happens - - expect(behavior.shouldRemove, isTrue); - }); - }); -} diff --git a/test/game/components/game_bloc_status_listener_test.dart b/test/game/components/game_bloc_status_listener_test.dart index d468ce2f..874f901c 100644 --- a/test/game/components/game_bloc_status_listener_test.dart +++ b/test/game/components/game_bloc_status_listener_test.dart @@ -36,6 +36,8 @@ class _TestGame extends Forge2DGame with HasTappables { Future pump( Iterable children, { PinballAudioPlayer? pinballAudioPlayer, + PlatformHelper? platformHelper, + GoogleWordCubit? googleWordBloc, }) async { return ensureAdd( FlameMultiBlocProvider( @@ -46,6 +48,9 @@ class _TestGame extends Forge2DGame with HasTappables { FlameBlocProvider.value( value: CharacterThemeCubit(), ), + FlameBlocProvider.value( + value: googleWordBloc ?? GoogleWordCubit(), + ), ], children: [ MultiFlameProvider( @@ -57,7 +62,7 @@ class _TestGame extends Forge2DGame with HasTappables { _MockAppLocalizations(), ), FlameProvider.value( - _MockPlatformHelper(), + platformHelper ?? PlatformHelper(), ), ], children: children, @@ -75,10 +80,11 @@ class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { class _MockShareRepository extends Mock implements ShareRepository {} -class _MockPlatformHelper extends Mock implements PlatformHelper { - @override - bool get isMobile => false; -} +class _MockPlatformHelper extends Mock implements PlatformHelper {} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +class _MockGoogleWordCubit extends Mock implements GoogleWordCubit {} class _MockAppLocalizations extends Mock implements AppLocalizations { @override @@ -196,7 +202,6 @@ void main() { await flipper.ensureAdd(behavior); expect(state.status, GameStatus.gameOver); - component.onNewState(state); await game.ready(); @@ -207,6 +212,77 @@ void main() { }, ); + flameTester.test( + 'removes PlungerKeyControllingBehavior from Plunger', + (game) async { + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + ); + + await plunger.ensureAdd( + FlameBlocProvider( + create: PlungerCubit.new, + children: [PlungerKeyControllingBehavior()], + ), + ); + + expect(state.status, GameStatus.gameOver); + component.onNewState(state); + await game.ready(); + + expect( + plunger.children.whereType(), + isEmpty, + ); + }, + ); + + flameTester.test( + 'removes PlungerPullingBehavior from Plunger', + (game) async { + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + ); + + await plunger.ensureAdd( + FlameBlocProvider( + create: PlungerCubit.new, + children: [ + PlungerPullingBehavior(strength: 0), + PlungerAutoPullingBehavior(strength: 0) + ], + ), + ); + + expect(state.status, GameStatus.gameOver); + component.onNewState(state); + await game.ready(); + + expect( + plunger.children.whereType(), + isEmpty, + ); + }, + ); + flameTester.test( 'plays the game over voice over', (game) async { @@ -263,7 +339,21 @@ void main() { ); flameTester.test( - 'adds key controlling behavior to Flippers when the game is started', + 'resets the GoogleWordCubit', + (game) async { + final googleWordBloc = _MockGoogleWordCubit(); + final component = GameBlocStatusListener(); + await game.pump([component], googleWordBloc: googleWordBloc); + + expect(state.status, equals(GameStatus.playing)); + component.onNewState(state); + + verify(googleWordBloc.onReset).called(1); + }, + ); + + flameTester.test( + 'adds FlipperKeyControllingBehavior to Flippers', (game) async { final component = GameBlocStatusListener(); final leaderboardRepository = _MockLeaderboardRepository(); @@ -288,6 +378,120 @@ void main() { ); }, ); + + flameTester.test( + 'adds PlungerKeyControllingBehavior to Plunger when on desktop', + (game) async { + final platformHelper = _MockPlatformHelper(); + when(() => platformHelper.isMobile).thenReturn(false); + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + platformHelper: platformHelper, + ); + await plunger.ensureAdd( + FlameBlocProvider( + create: _MockPlungerCubit.new, + ), + ); + + expect(state.status, GameStatus.playing); + + component.onNewState(state); + await game.ready(); + + expect( + plunger + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds PlungerPullingBehavior to Plunger when on desktop', + (game) async { + final platformHelper = _MockPlatformHelper(); + when(() => platformHelper.isMobile).thenReturn(false); + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + platformHelper: platformHelper, + ); + await plunger.ensureAdd( + FlameBlocProvider( + create: _MockPlungerCubit.new, + ), + ); + + expect(state.status, GameStatus.playing); + + component.onNewState(state); + await game.ready(); + + expect( + plunger.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds PlungerAutoPullingBehavior to Plunger when on mobile', + (game) async { + final platformHelper = _MockPlatformHelper(); + when(() => platformHelper.isMobile).thenReturn(true); + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + platformHelper: platformHelper, + ); + await plunger.ensureAdd( + FlameBlocProvider( + create: _MockPlungerCubit.new, + ), + ); + + expect(state.status, GameStatus.playing); + + component.onNewState(state); + await game.ready(); + + expect( + plunger + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); }); }); }); diff --git a/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart b/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart index e5a25264..1e6edc69 100644 --- a/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart +++ b/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart @@ -119,8 +119,8 @@ void main() { ); flameTester.testGameWidget( - 'adds BonusBallSpawningBehavior to the game when all letters ' - 'in google word are activated', + 'adds BonusBallSpawningBehavior and GoogleWordAnimatingBehavior ' + 'to the game when all letters in google word are activated', setUp: (game, tester) async { final behavior = GoogleWordBonusBehavior(); final parent = GoogleGallery.test(); @@ -161,6 +161,10 @@ void main() { game.descendants().whereType().length, equals(1), ); + expect( + game.descendants().whereType().length, + equals(1), + ); }, ); }); diff --git a/test/game/components/google_gallery/google_gallery_test.dart b/test/game/components/google_gallery/google_gallery_test.dart index 9551285f..719be2dc 100644 --- a/test/game/components/google_gallery/google_gallery_test.dart +++ b/test/game/components/google_gallery/google_gallery_test.dart @@ -97,6 +97,20 @@ void main() { }, ); + flameTester.test( + 'RolloverNoiseBehavior to GoogleRollovers', + (game) async { + await game.pump(GoogleGallery()); + + game.descendants().whereType().forEach( + (rollover) => expect( + rollover.firstChild(), + isNotNull, + ), + ); + }, + ); + flameTester.test('a GoogleWordBonusBehavior', (game) async { final component = GoogleGallery(); await game.pump(component); diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 289fb4fa..a95f329b 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame/input.dart'; +import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -395,7 +396,7 @@ void main() { }); group('plunger control', () { - flameTester.test('tap down moves plunger down', (game) async { + flameTester.test('tap down emits plunging', (game) async { await game.ready(); final eventPosition = _MockEventPosition(); @@ -408,13 +409,15 @@ void main() { when(() => tapDownEvent.eventPosition).thenReturn(eventPosition); when(() => tapDownEvent.raw).thenReturn(raw); - final plunger = game.descendants().whereType().first; - game.onTapDown(0, tapDownEvent); - game.update(1); + final plungerBloc = game + .descendants() + .whereType>() + .single + .bloc; - expect(plunger.body.linearVelocity.y, isPositive); + expect(plungerBloc.state, PlungerState.pulling); }); }); }); diff --git a/test/game/view/widgets/replay_button_overlay_test.dart b/test/game/view/widgets/replay_button_overlay_test.dart index 1497031a..5c3e4884 100644 --- a/test/game/view/widgets/replay_button_overlay_test.dart +++ b/test/game/view/widgets/replay_button_overlay_test.dart @@ -8,24 +8,32 @@ import '../../../helpers/helpers.dart'; class _MockStartGameBloc extends Mock implements StartGameBloc {} +class _MockGameBloc extends Mock implements GameBloc {} + void main() { group('ReplayButtonOverlay', () { late StartGameBloc startGameBloc; + late _MockGameBloc gameBloc; setUp(() async { await mockFlameImages(); startGameBloc = _MockStartGameBloc(); + gameBloc = _MockGameBloc(); whenListen( startGameBloc, Stream.value(const StartGameState.initial()), initialState: const StartGameState.initial(), ); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); }); testWidgets('renders correctly', (tester) async { await tester.pumpApp(const ReplayButtonOverlay()); - expect(find.text('Replay'), findsOneWidget); }); @@ -33,6 +41,7 @@ void main() { (tester) async { await tester.pumpApp( const ReplayButtonOverlay(), + gameBloc: gameBloc, startGameBloc: startGameBloc, ); @@ -41,5 +50,19 @@ void main() { verify(() => startGameBloc.add(const ReplayTapped())).called(1); }); + + testWidgets('adds GameStarted event to GameBloc when tapped', + (tester) async { + await tester.pumpApp( + const ReplayButtonOverlay(), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + + await tester.tap(find.text('Replay')); + await tester.pump(); + + verify(() => gameBloc.add(const GameStarted())).called(1); + }); }); } diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 613fd5b8..2f16567a 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -1,3 +1,2 @@ -export 'key_testers.dart'; export 'mock_flame_images.dart'; export 'pump_app.dart'; diff --git a/test/helpers/key_testers.dart b/test/helpers/key_testers.dart deleted file mode 100644 index ff870d6c..00000000 --- a/test/helpers/key_testers.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:meta/meta.dart'; -import 'package:mocktail/mocktail.dart'; - -class _MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { - @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return super.toString(); - } -} - -class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { - @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return super.toString(); - } -} - -@isTest -void testRawKeyUpEvents( - List keys, - Function(RawKeyUpEvent) test, -) { - for (final key in keys) { - test(_mockKeyUpEvent(key)); - } -} - -RawKeyUpEvent _mockKeyUpEvent(LogicalKeyboardKey key) { - final event = _MockRawKeyUpEvent(); - when(() => event.logicalKey).thenReturn(key); - return event; -} - -@isTest -void testRawKeyDownEvents( - List keys, - Function(RawKeyDownEvent) test, -) { - for (final key in keys) { - test(_mockKeyDownEvent(key)); - } -} - -RawKeyDownEvent _mockKeyDownEvent(LogicalKeyboardKey key) { - final event = _MockRawKeyDownEvent(); - when(() => event.logicalKey).thenReturn(key); - return event; -}