diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a805cebc..5bef442d 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -11,6 +11,6 @@ jobs: uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 with: flutter_channel: stable - flutter_version: 2.10.0 + flutter_version: 2.10.5 coverage_excludes: "lib/gen/*.dart" test_optimization: false diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index a44d2e33..ae3094e1 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -39,6 +39,7 @@ class App extends StatelessWidget { providers: [ BlocProvider(create: (_) => CharacterThemeCubit()), BlocProvider(create: (_) => StartGameBloc()), + BlocProvider(create: (_) => GameBloc()), ], child: MaterialApp( title: 'I/O Pinball', diff --git a/lib/footer/footer.dart b/lib/footer/footer.dart deleted file mode 100644 index df3dbd2f..00000000 --- a/lib/footer/footer.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball_ui/pinball_ui.dart'; - -/// {@template footer} -/// Footer widget with links to the main tech stack. -/// {@endtemplate} -class Footer extends StatelessWidget { - /// {@macro footer} - const Footer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(50, 0, 50, 32), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - _MadeWithFlutterAndFirebase(), - _GoogleIO(), - ], - ), - ); - } -} - -class _GoogleIO extends StatelessWidget { - const _GoogleIO({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final theme = Theme.of(context); - return Text( - l10n.footerGoogleIOText, - style: theme.textTheme.bodyText1!.copyWith(color: PinballColors.white), - ); - } -} - -class _MadeWithFlutterAndFirebase extends StatelessWidget { - const _MadeWithFlutterAndFirebase({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final theme = Theme.of(context); - return RichText( - textAlign: TextAlign.center, - text: TextSpan( - text: l10n.footerMadeWithText, - style: theme.textTheme.bodyText1!.copyWith(color: PinballColors.white), - children: [ - TextSpan( - text: l10n.footerFlutterLinkText, - recognizer: TapGestureRecognizer() - ..onTap = () => openLink('https://flutter.dev'), - style: const TextStyle( - decoration: TextDecoration.underline, - ), - ), - const TextSpan(text: ' & '), - TextSpan( - text: l10n.footerFirebaseLinkText, - recognizer: TapGestureRecognizer() - ..onTap = () => openLink('https://firebase.google.com'), - style: const TextStyle( - decoration: TextDecoration.underline, - ), - ), - ], - ), - ); - } -} diff --git a/lib/game/behaviors/ball_spawning_behavior.dart b/lib/game/behaviors/ball_spawning_behavior.dart index bb7ea0a6..6d9748dd 100644 --- a/lib/game/behaviors/ball_spawning_behavior.dart +++ b/lib/game/behaviors/ball_spawning_behavior.dart @@ -23,13 +23,15 @@ class BallSpawningBehavior extends Component void onNewState(GameState state) { final plunger = gameRef.descendants().whereType().single; final canvas = gameRef.descendants().whereType().single; - final characterThemeBloc = readProvider(); - final ball = ControlledBall.launch( - characterTheme: characterThemeBloc.state.characterTheme, - )..initialPosition = Vector2( + final characterTheme = + readProvider().state.characterTheme; + final ball = Ball(assetPath: characterTheme.ball.keyName) + ..initialPosition = Vector2( plunger.body.position.x, plunger.body.position.y - Ball.size.y, - ); + ) + ..layer = Layer.launcher + ..zIndex = ZIndexes.ballOnLaunchRamp; canvas.add(ball); } diff --git a/lib/game/behaviors/behaviors.dart b/lib/game/behaviors/behaviors.dart index 4e4aa52a..301bc61e 100644 --- a/lib/game/behaviors/behaviors.dart +++ b/lib/game/behaviors/behaviors.dart @@ -1,5 +1,6 @@ export 'ball_spawning_behavior.dart'; export 'ball_theming_behavior.dart'; +export 'bonus_noise_behavior.dart'; export 'bumper_noise_behavior.dart'; export 'camera_focusing_behavior.dart'; export 'scoring_behavior.dart'; diff --git a/lib/game/behaviors/bonus_noise_behavior.dart b/lib/game/behaviors/bonus_noise_behavior.dart new file mode 100644 index 00000000..9d67e964 --- /dev/null +++ b/lib/game/behaviors/bonus_noise_behavior.dart @@ -0,0 +1,41 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Behavior that handles playing a bonus sound effect +class BonusNoiseBehavior extends Component { + @override + Future onLoad() async { + await add( + FlameBlocListener( + listenWhen: (previous, current) { + return previous.bonusHistory.length != current.bonusHistory.length; + }, + onNewState: (state) { + final bonus = state.bonusHistory.last; + final audioPlayer = readProvider(); + + switch (bonus) { + case GameBonus.googleWord: + audioPlayer.play(PinballAudio.google); + break; + case GameBonus.sparkyTurboCharge: + audioPlayer.play(PinballAudio.sparky); + break; + case GameBonus.dinoChomp: + // TODO(erickzanardo): Add sound + break; + case GameBonus.androidSpaceship: + // TODO(erickzanardo): Add sound + break; + case GameBonus.dashNest: + // TODO(erickzanardo): Add sound + break; + } + }, + ), + ); + } +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index b96b6a65..08dc5cb0 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_ball.dart'; export 'controlled_flipper.dart'; export 'controlled_plunger.dart'; export 'dino_desert/dino_desert.dart'; @@ -12,4 +11,4 @@ export 'google_word/google_word.dart'; export 'launcher.dart'; export 'multiballs/multiballs.dart'; export 'multipliers/multipliers.dart'; -export 'sparky_scorch.dart'; +export 'sparky_scorch/sparky_scorch.dart'; diff --git a/lib/game/components/controlled_ball.dart b/lib/game/components/controlled_ball.dart deleted file mode 100644 index 241465dd..00000000 --- a/lib/game/components/controlled_ball.dart +++ /dev/null @@ -1,66 +0,0 @@ -// ignore_for_file: avoid_renaming_method_parameters - -import 'package:flame/components.dart'; -import 'package:flame_bloc/flame_bloc.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; -import 'package:pinball_theme/pinball_theme.dart'; - -/// {@template controlled_ball} -/// A [Ball] with a [BallController] attached. -/// -/// When a [Ball] is lost, if there aren't more [Ball]s in play and the game is -/// not over, a new [Ball] will be spawned. -/// {@endtemplate} -class ControlledBall extends Ball with Controls { - /// A [Ball] that launches from the [Plunger]. - ControlledBall.launch({ - required CharacterTheme characterTheme, - }) : super(assetPath: characterTheme.ball.keyName) { - controller = BallController(this); - layer = Layer.launcher; - zIndex = ZIndexes.ballOnLaunchRamp; - } - - /// {@macro controlled_ball} - ControlledBall.bonus({ - required CharacterTheme characterTheme, - }) : super(assetPath: characterTheme.ball.keyName) { - controller = BallController(this); - zIndex = ZIndexes.ballOnBoard; - } - - /// [Ball] used in [DebugPinballGame]. - ControlledBall.debug() : super() { - controller = BallController(this); - zIndex = ZIndexes.ballOnBoard; - } -} - -/// {@template ball_controller} -/// Controller attached to a [Ball] that handles its game related logic. -/// {@endtemplate} -class BallController extends ComponentController - with FlameBlocReader { - /// {@macro ball_controller} - BallController(Ball ball) : super(ball); - - /// Stops the [Ball] inside of the [SparkyComputer] while the turbo charge - /// sequence runs, then boosts the ball out of the computer. - Future turboCharge() async { - bloc.add(const SparkyTurboChargeActivated()); - - component.stop(); - // TODO(alestiago): Refactor this hard coded duration once the following is - // merged: - // https://github.com/flame-engine/flame/pull/1564 - await Future.delayed( - const Duration(milliseconds: 2583), - ); - component.resume(); - await component.add( - BallTurboChargingBehavior(impulse: Vector2(40, 110)), - ); - } -} diff --git a/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart b/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart index 2eb41694..1240fc2f 100644 --- a/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart +++ b/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart @@ -41,11 +41,12 @@ class FlutterForestBonusBehavior extends Component if (signpost.bloc.isFullyProgressed()) { bloc.add(const BonusActivated(GameBonus.dashNest)); + final characterTheme = + readProvider().state.characterTheme; canvas.add( - ControlledBall.bonus( - characterTheme: - readProvider().state.characterTheme, - )..initialPosition = Vector2(29.2, -24.5), + Ball(assetPath: characterTheme.ball.keyName) + ..initialPosition = Vector2(29.2, -24.5) + ..zIndex = ZIndexes.ballOnBoard, ); animatronic.playing = true; signpost.bloc.onProgressed(); diff --git a/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart b/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart index e49d4537..586b8547 100644 --- a/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart +++ b/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart @@ -1,7 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame_bloc/flame_bloc.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'; @@ -22,7 +21,6 @@ class GoogleWordBonusBehavior extends Component .every((letter) => letter.bloc.state == GoogleLetterState.lit); if (achievedBonus) { - readProvider().play(PinballAudio.google); bloc.add(const BonusActivated(GameBonus.googleWord)); for (final letter in googleLetters) { letter.bloc.onReset(); diff --git a/lib/game/components/sparky_scorch/behaviors/behaviors.dart b/lib/game/components/sparky_scorch/behaviors/behaviors.dart new file mode 100644 index 00000000..3281bb69 --- /dev/null +++ b/lib/game/components/sparky_scorch/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'sparky_computer_bonus_behavior.dart'; diff --git a/lib/game/components/sparky_scorch/behaviors/sparky_computer_bonus_behavior.dart b/lib/game/components/sparky_scorch/behaviors/sparky_computer_bonus_behavior.dart new file mode 100644 index 00000000..15deab29 --- /dev/null +++ b/lib/game/components/sparky_scorch/behaviors/sparky_computer_bonus_behavior.dart @@ -0,0 +1,28 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Adds a [GameBonus.sparkyTurboCharge] when a [Ball] enters the +/// [SparkyComputer]. +class SparkyComputerBonusBehavior extends Component + with ParentIsA, FlameBlocReader { + @override + void onMount() { + super.onMount(); + final sparkyComputer = parent.firstChild()!; + final animatronic = parent.firstChild()!; + + // TODO(alestiago): Refactor subscription management once the following is + // merged: + // https://github.com/flame-engine/flame/pull/1538 + sparkyComputer.bloc.stream.listen((state) async { + final listenWhen = state == SparkyComputerState.withBall; + if (!listenWhen) return; + + bloc.add(const BonusActivated(GameBonus.sparkyTurboCharge)); + animatronic.playing = true; + }); + } +} diff --git a/lib/game/components/sparky_scorch.dart b/lib/game/components/sparky_scorch/sparky_scorch.dart similarity index 50% rename from lib/game/components/sparky_scorch.dart rename to lib/game/components/sparky_scorch/sparky_scorch.dart index b820e89d..da624361 100644 --- a/lib/game/components/sparky_scorch.dart +++ b/lib/game/components/sparky_scorch/sparky_scorch.dart @@ -1,9 +1,9 @@ // ignore_for_file: avoid_renaming_method_parameters import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; import 'package:pinball/game/behaviors/behaviors.dart'; -import 'package:pinball/game/components/components.dart'; +import 'package:pinball/game/components/sparky_scorch/behaviors/behaviors.dart'; import 'package:pinball_components/pinball_components.dart'; /// {@template sparky_scorch} @@ -33,51 +33,20 @@ class SparkyScorch extends Component { BumperNoiseBehavior(), ], )..initialPosition = Vector2(-3.3, -52.55), - SparkyComputerSensor()..initialPosition = Vector2(-13.2, -49.9), SparkyAnimatronic()..position = Vector2(-14, -58.2), - SparkyComputer(), - ], - ); -} - -/// {@template sparky_computer_sensor} -/// Small sensor body used to detect when a ball has entered the -/// [SparkyComputer]. -/// {@endtemplate} -class SparkyComputerSensor extends BodyComponent - with InitialPosition, ContactCallbacks { - /// {@macro sparky_computer_sensor} - SparkyComputerSensor() - : super( - renderBody: false, - children: [ - ScoringContactBehavior(points: Points.twentyThousand), + SparkyComputer( + children: [ + ScoringContactBehavior(points: Points.twoHundredThousand) + ..applyTo(['turbo_charge_sensor']), + ], + ), + SparkyComputerBonusBehavior(), ], ); - @override - Body createBody() { - final shape = PolygonShape() - ..setAsBox( - 1, - 0.1, - Vector2.zero(), - -0.18, - ); - final fixtureDef = FixtureDef(shape, isSensor: true); - final bodyDef = BodyDef( - position: initialPosition, - userData: this, - ); - return world.createBody(bodyDef)..createFixture(fixtureDef); - } - - @override - void beginContact(Object other, Contact contact) { - super.beginContact(other, contact); - if (other is! ControlledBall) return; - - other.controller.turboCharge(); - gameRef.firstChild()?.playing = true; - } + /// Creates [SparkyScorch] without any children. + /// + /// This can be used for testing [SparkyScorch]'s behaviors in isolation. + @visibleForTesting + SparkyScorch.test(); } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index a103ae84..964bbff3 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -24,7 +24,8 @@ class PinballGame extends PinballForge2DGame required GameBloc gameBloc, required AppLocalizations l10n, required PinballPlayer player, - }) : _gameBloc = gameBloc, + }) : focusNode = FocusNode(), + _gameBloc = gameBloc, _player = player, _characterThemeBloc = characterThemeBloc, _l10n = l10n, @@ -40,6 +41,8 @@ class PinballGame extends PinballForge2DGame @override Color backgroundColor() => Colors.transparent; + final FocusNode focusNode; + final CharacterThemeCubit _characterThemeBloc; final PinballPlayer _player; @@ -71,6 +74,7 @@ class PinballGame extends PinballForge2DGame FlameProvider.value(_l10n), ], children: [ + BonusNoiseBehavior(), GameBlocStatusListener(), BallSpawningBehavior(), BallThemingBehavior(), @@ -195,8 +199,7 @@ class DebugPinballGame extends PinballGame with FPSCounter, PanDetector { if (info.raw.kind == PointerDeviceKind.mouse) { final canvas = descendants().whereType().single; - final ball = ControlledBall.debug() - ..initialPosition = info.eventPosition.game; + final ball = Ball()..initialPosition = info.eventPosition.game; canvas.add(ball); } } @@ -223,7 +226,7 @@ class DebugPinballGame extends PinballGame with FPSCounter, PanDetector { void _turboChargeBall(Vector2 line) { final canvas = descendants().whereType().single; - final ball = ControlledBall.debug()..initialPosition = lineStart!; + final ball = Ball()..initialPosition = lineStart!; final impulse = line * -1 * 10; ball.add(BallTurboChargingBehavior(impulse: impulse)); canvas.add(ball); @@ -269,7 +272,7 @@ class _DebugInformation extends Component with HasGameRef { void render(Canvas canvas) { final debugText = [ 'FPS: ${gameRef.fps().toStringAsFixed(1)}', - 'BALLS: ${gameRef.descendants().whereType().length}', + 'BALLS: ${gameRef.descendants().whereType().length}', ].join(' | '); final height = _debugTextPaint.measureTextHeight(debugText); diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 2fd37f05..354d61ed 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -7,7 +7,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/assets_manager/assets_manager.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/gen/gen.dart'; import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/more_information/more_information.dart'; import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; @@ -21,15 +23,9 @@ class PinballGamePage extends StatelessWidget { final bool isDebugMode; - static Route route({ - bool isDebugMode = kDebugMode, - }) { + static Route route({bool isDebugMode = kDebugMode}) { return MaterialPageRoute( - builder: (context) { - return PinballGamePage( - isDebugMode: isDebugMode, - ); - }, + builder: (_) => PinballGamePage(isDebugMode: isDebugMode), ); } @@ -38,41 +34,33 @@ class PinballGamePage extends StatelessWidget { final characterThemeBloc = context.read(); final player = context.read(); final leaderboardRepository = context.read(); + final gameBloc = context.read(); + final game = isDebugMode + ? DebugPinballGame( + characterThemeBloc: characterThemeBloc, + player: player, + leaderboardRepository: leaderboardRepository, + l10n: context.l10n, + gameBloc: gameBloc, + ) + : PinballGame( + characterThemeBloc: characterThemeBloc, + player: player, + leaderboardRepository: leaderboardRepository, + l10n: context.l10n, + gameBloc: gameBloc, + ); + + final loadables = [ + ...game.preLoadAssets(), + ...player.load(), + ...BonusAnimation.loadAssets(), + ...SelectedCharacter.loadAssets(), + ]; return BlocProvider( - create: (_) => GameBloc(), - child: Builder( - builder: (context) { - final gameBloc = context.read(); - final game = isDebugMode - ? DebugPinballGame( - characterThemeBloc: characterThemeBloc, - player: player, - leaderboardRepository: leaderboardRepository, - l10n: context.l10n, - gameBloc: gameBloc, - ) - : PinballGame( - characterThemeBloc: characterThemeBloc, - player: player, - leaderboardRepository: leaderboardRepository, - l10n: context.l10n, - gameBloc: gameBloc, - ); - - final loadables = [ - ...game.preLoadAssets(), - ...player.load(), - ...BonusAnimation.loadAssets(), - ...SelectedCharacter.loadAssets(), - ]; - - return BlocProvider( - create: (_) => AssetsManagerCubit(loadables)..load(), - child: PinballGameView(game: game), - ); - }, - ), + create: (_) => AssetsManagerCubit(loadables)..load(), + child: PinballGameView(game: game), ); } } @@ -117,22 +105,31 @@ class PinballGameLoadedView extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: GameWidget( - game: game, - initialActiveOverlays: const [PinballGame.playButtonOverlay], - overlayBuilderMap: { - PinballGame.playButtonOverlay: (context, game) { - return const Positioned( - bottom: 20, - right: 0, - left: 0, - child: PlayButtonOverlay(), - ); - }, + child: MouseRegion( + onHover: (_) { + if (!game.focusNode.hasFocus) { + game.focusNode.requestFocus(); + } }, + child: GameWidget( + game: game, + focusNode: game.focusNode, + initialActiveOverlays: const [PinballGame.playButtonOverlay], + overlayBuilderMap: { + PinballGame.playButtonOverlay: (context, game) { + return const Positioned( + bottom: 20, + right: 0, + left: 0, + child: PlayButtonOverlay(), + ); + }, + }, + ), ), ), const _PositionedGameHud(), + const _PositionedInfoIcon(), ], ), ); @@ -150,6 +147,7 @@ class _PositionedGameHud extends StatelessWidget { final isGameOver = context.select( (GameBloc bloc) => bloc.state.status.isGameOver, ); + final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; final screenWidth = MediaQuery.of(context).size.width; final leftMargin = (screenWidth / 2) - (gameWidgetWidth / 1.8); @@ -165,3 +163,27 @@ class _PositionedGameHud extends StatelessWidget { ); } } + +class _PositionedInfoIcon extends StatelessWidget { + const _PositionedInfoIcon({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + left: 0, + child: BlocBuilder( + builder: (context, state) { + return Visibility( + visible: state.status.isGameOver, + child: IconButton( + iconSize: 50, + icon: Assets.images.linkBox.infoIcon.image(), + onPressed: () => showMoreInformationDialog(context), + ), + ); + }, + ), + ); + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3d15d9fd..aa1a24f6 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -104,22 +104,6 @@ "@toSubmit": { "description": "Ending text displayed on initials input screen informational text span" }, - "footerMadeWithText": "Made with ", - "@footerMadeWithText": { - "description": "Text shown on the footer which mentions technologies used to build the app." - }, - "footerFlutterLinkText": "Flutter", - "@footerFlutterLinkText": { - "description": "Text on the link shown on the footer which navigates to the Flutter page" - }, - "footerFirebaseLinkText": "Firebase", - "@footerFirebaseLinkText": { - "description": "Text on the link shown on the footer which navigates to the Firebase page" - }, - "footerGoogleIOText": "Google I/O", - "@footerGoogleIOText": { - "description": "Text shown on the footer which mentions Google I/O" - }, "linkBoxTitle": "Resources", "@linkBoxTitle": { "description": "Text shown on the link box title section." diff --git a/lib/more_information/more_information.dart b/lib/more_information/more_information.dart new file mode 100644 index 00000000..317461ed --- /dev/null +++ b/lib/more_information/more_information.dart @@ -0,0 +1 @@ +export 'more_information_dialog.dart'; diff --git a/lib/more_information/more_information_dialog.dart b/lib/more_information/more_information_dialog.dart new file mode 100644 index 00000000..179c06f5 --- /dev/null +++ b/lib/more_information/more_information_dialog.dart @@ -0,0 +1,218 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +/// Inflates [MoreInformationDialog] using [showDialog]. +Future showMoreInformationDialog(BuildContext context) { + final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; + + return showDialog( + context: context, + barrierColor: PinballColors.transparent, + barrierDismissible: true, + builder: (_) { + return Center( + child: SizedBox( + height: gameWidgetWidth * 0.87, + width: gameWidgetWidth, + child: const MoreInformationDialog(), + ), + ); + }, + ); +} + +/// {@template more_information_dialog} +/// Dialog used to show informational links +/// {@endtemplate} +class MoreInformationDialog extends StatelessWidget { + /// {@macro more_information_dialog} + const MoreInformationDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Material( + color: PinballColors.transparent, + child: _LinkBoxDecoration( + child: Column( + children: const [ + SizedBox(height: 16), + _LinkBoxHeader(), + Expanded( + child: _LinkBoxBody(), + ), + ], + ), + ), + ); + } +} + +class _LinkBoxHeader extends StatelessWidget { + const _LinkBoxHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final indent = MediaQuery.of(context).size.width / 5; + + return Column( + children: [ + Text( + l10n.linkBoxTitle, + style: Theme.of(context).textTheme.headline3!.copyWith( + color: PinballColors.blue, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Divider( + color: PinballColors.white, + endIndent: indent, + indent: indent, + thickness: 2, + ), + ], + ); + } +} + +class _LinkBoxDecoration extends StatelessWidget { + const _LinkBoxDecoration({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const CrtBackground().copyWith( + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all( + color: PinballColors.white, + width: 5, + ), + ), + child: child, + ); + } +} + +class _LinkBoxBody extends StatelessWidget { + const _LinkBoxBody({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const _MadeWithFlutterAndFirebase(), + _TextLink( + text: l10n.linkBoxOpenSourceCode, + link: _MoreInformationUrl.openSourceCode, + ), + _TextLink( + text: l10n.linkBoxGoogleIOText, + link: _MoreInformationUrl.googleIOEvent, + ), + _TextLink( + text: l10n.linkBoxFlutterGames, + link: _MoreInformationUrl.flutterGamesWebsite, + ), + _TextLink( + text: l10n.linkBoxHowItsMade, + link: _MoreInformationUrl.howItsMadeArticle, + ), + _TextLink( + text: l10n.linkBoxTermsOfService, + link: _MoreInformationUrl.termsOfService, + ), + _TextLink( + text: l10n.linkBoxPrivacyPolicy, + link: _MoreInformationUrl.privacyPolicy, + ), + ], + ); + } +} + +class _TextLink extends StatelessWidget { + const _TextLink({ + Key? key, + required this.text, + required this.link, + }) : super(key: key); + + final String text; + final String link; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: () => openLink(link), + child: Text( + text, + style: theme.textTheme.headline5!.copyWith( + color: PinballColors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ); + } +} + +class _MadeWithFlutterAndFirebase extends StatelessWidget { + const _MadeWithFlutterAndFirebase({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + text: l10n.linkBoxMadeWithText, + style: theme.textTheme.headline5!.copyWith(color: PinballColors.white), + children: [ + TextSpan( + text: l10n.linkBoxFlutterLinkText, + recognizer: TapGestureRecognizer() + ..onTap = () => openLink(_MoreInformationUrl.flutterWebsite), + style: const TextStyle( + decoration: TextDecoration.underline, + ), + ), + const TextSpan(text: ' & '), + TextSpan( + text: l10n.linkBoxFirebaseLinkText, + recognizer: TapGestureRecognizer() + ..onTap = () => openLink(_MoreInformationUrl.firebaseWebsite), + style: theme.textTheme.headline5!.copyWith( + decoration: TextDecoration.underline, + ), + ), + ], + ), + ); + } +} + +abstract class _MoreInformationUrl { + static const flutterWebsite = 'https://flutter.dev'; + static const firebaseWebsite = 'https://firebase.google.com'; + static const openSourceCode = 'https://github.com/VGVentures/pinball'; + static const googleIOEvent = 'https://events.google.com/io/'; + static const flutterGamesWebsite = 'http://flutter.dev/games'; + static const howItsMadeArticle = + 'https://medium.com/flutter/i-o-pinball-powered-by-flutter-and-firebase-d22423f3f5d'; + static const termsOfService = 'https://policies.google.com/terms'; + static const privacyPolicy = 'https://policies.google.com/privacy'; +} diff --git a/packages/pinball_audio/assets/sfx/sparky.mp3 b/packages/pinball_audio/assets/sfx/sparky.mp3 new file mode 100644 index 00000000..14bf0ec6 Binary files /dev/null and b/packages/pinball_audio/assets/sfx/sparky.mp3 differ diff --git a/packages/pinball_audio/lib/gen/assets.gen.dart b/packages/pinball_audio/lib/gen/assets.gen.dart index 916906c4..08e83d87 100644 --- a/packages/pinball_audio/lib/gen/assets.gen.dart +++ b/packages/pinball_audio/lib/gen/assets.gen.dart @@ -14,13 +14,13 @@ class $AssetsMusicGen { class $AssetsSfxGen { const $AssetsSfxGen(); - String get afterLaunch => 'assets/sfx/after_launch.mp3'; String get bumperA => 'assets/sfx/bumper_a.mp3'; String get bumperB => 'assets/sfx/bumper_b.mp3'; String get gameOverVoiceOver => 'assets/sfx/game_over_voice_over.mp3'; String get google => 'assets/sfx/google.mp3'; String get ioPinballVoiceOver => 'assets/sfx/io_pinball_voice_over.mp3'; String get launcher => 'assets/sfx/launcher.mp3'; + String get sparky => 'assets/sfx/sparky.mp3'; } class Assets { diff --git a/packages/pinball_audio/lib/src/pinball_audio.dart b/packages/pinball_audio/lib/src/pinball_audio.dart index 56289417..95c993c5 100644 --- a/packages/pinball_audio/lib/src/pinball_audio.dart +++ b/packages/pinball_audio/lib/src/pinball_audio.dart @@ -25,6 +25,9 @@ enum PinballAudio { /// Launcher launcher, + + /// Sparky + sparky, } /// Defines the contract of the creation of an [AudioPool]. @@ -161,6 +164,11 @@ class PinballPlayer { playSingleAudio: _playSingleAudio, path: Assets.sfx.google, ), + PinballAudio.sparky: _SimplePlayAudio( + preCacheSingleAudio: _preCacheSingleAudio, + playSingleAudio: _playSingleAudio, + path: Assets.sfx.sparky, + ), PinballAudio.launcher: _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 fdcd661b..39060eb2 100644 --- a/packages/pinball_audio/test/src/pinball_audio_test.dart +++ b/packages/pinball_audio/test/src/pinball_audio_test.dart @@ -141,6 +141,10 @@ void main() { () => preCacheSingleAudio .onCall('packages/pinball_audio/assets/sfx/google.mp3'), ).called(1); + verify( + () => preCacheSingleAudio + .onCall('packages/pinball_audio/assets/sfx/sparky.mp3'), + ).called(1); verify( () => preCacheSingleAudio.onCall( 'packages/pinball_audio/assets/sfx/io_pinball_voice_over.mp3', @@ -211,7 +215,7 @@ void main() { }); }); - group('googleBonus', () { + group('google', () { test('plays the correct file', () async { await Future.wait(player.load()); player.play(PinballAudio.google); @@ -223,6 +227,18 @@ void main() { }); }); + group('sparky', () { + test('plays the correct file', () async { + await Future.wait(player.load()); + player.play(PinballAudio.sparky); + + verify( + () => playSingleAudio + .onCall('packages/pinball_audio/${Assets.sfx.sparky}'), + ).called(1); + }); + }); + group('launcher', () { test('plays the correct file', () async { await Future.wait(player.load()); diff --git a/packages/pinball_components/lib/src/components/chrome_dino/chrome_dino.dart b/packages/pinball_components/lib/src/components/chrome_dino/chrome_dino.dart index 38a335b9..61052b60 100644 --- a/packages/pinball_components/lib/src/components/chrome_dino/chrome_dino.dart +++ b/packages/pinball_components/lib/src/components/chrome_dino/chrome_dino.dart @@ -61,13 +61,13 @@ class ChromeDino extends BodyComponent List _createFixtureDefs() { const mouthAngle = -(halfSweepingAngle + 0.28); - final size = Vector2(5.5, 6); + final size = Vector2(6, 6); final topEdge = PolygonShape() ..setAsBox( size.x / 2, 0.1, - initialPosition + Vector2(-4.2, -1.4), + initialPosition + Vector2(-4, -1.4), mouthAngle, ); final topEdgeFixtureDef = FixtureDef(topEdge, density: 100); @@ -76,7 +76,7 @@ class ChromeDino extends BodyComponent ..setAsBox( 0.1, size.y / 2, - initialPosition + Vector2(-1.3, 0.5), + initialPosition + Vector2(-1, 0.5), -halfSweepingAngle, ); final backEdgeFixtureDef = FixtureDef(backEdge, density: 100); @@ -85,7 +85,7 @@ class ChromeDino extends BodyComponent ..setAsBox( size.x / 2, 0.1, - initialPosition + Vector2(-3.5, 4.7), + initialPosition + Vector2(-3.3, 4.7), mouthAngle, ); final bottomEdgeFixtureDef = FixtureDef( @@ -110,7 +110,7 @@ class ChromeDino extends BodyComponent ..setAsBox( 0.2, 0.2, - initialPosition + Vector2(-3.5, 1.5), + initialPosition + Vector2(-3, 1.5), 0, ); final insideSensorFixtureDef = FixtureDef( diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 55fe6bb5..54345772 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -36,5 +36,5 @@ export 'spaceship_rail.dart'; export 'spaceship_ramp/spaceship_ramp.dart'; export 'sparky_animatronic.dart'; export 'sparky_bumper/sparky_bumper.dart'; -export 'sparky_computer.dart'; +export 'sparky_computer/sparky_computer.dart'; export 'z_indexes.dart'; diff --git a/packages/pinball_components/lib/src/components/sparky_computer/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/sparky_computer/behaviors/behaviors.dart new file mode 100644 index 00000000..7befc568 --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_computer/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'sparky_computer_sensor_ball_contact_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/sparky_computer/behaviors/sparky_computer_sensor_ball_contact_behavior.dart b/packages/pinball_components/lib/src/components/sparky_computer/behaviors/sparky_computer_sensor_ball_contact_behavior.dart new file mode 100644 index 00000000..8e83f61f --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_computer/behaviors/sparky_computer_sensor_ball_contact_behavior.dart @@ -0,0 +1,35 @@ +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'; + +/// {@template sparky_computer_sensor_ball_contact_behavior} +/// When a [Ball] enters the [SparkyComputer] it is stopped for a period of time +/// before a [BallTurboChargingBehavior] is applied to it. +/// {@endtemplate} +class SparkyComputerSensorBallContactBehavior + extends ContactBehavior { + @override + Future beginContact(Object other, Contact contact) async { + super.beginContact(other, contact); + if (other is! Ball) return; + + other.stop(); + parent.bloc.onBallEntered(); + await parent.add( + TimerComponent( + period: 1.5, + removeOnFinish: true, + onTick: () async { + other.resume(); + await other.add( + BallTurboChargingBehavior( + impulse: Vector2(40, 110), + ), + ); + parent.bloc.onBallTurboCharged(); + }, + ), + ); + } +} diff --git a/packages/pinball_components/lib/src/components/sparky_computer/cubit/sparky_computer_cubit.dart b/packages/pinball_components/lib/src/components/sparky_computer/cubit/sparky_computer_cubit.dart new file mode 100644 index 00000000..e86defcd --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_computer/cubit/sparky_computer_cubit.dart @@ -0,0 +1,17 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; + +part 'sparky_computer_state.dart'; + +class SparkyComputerCubit extends Cubit { + SparkyComputerCubit() : super(SparkyComputerState.withoutBall); + + void onBallEntered() { + emit(SparkyComputerState.withBall); + } + + void onBallTurboCharged() { + emit(SparkyComputerState.withoutBall); + } +} diff --git a/packages/pinball_components/lib/src/components/sparky_computer/cubit/sparky_computer_state.dart b/packages/pinball_components/lib/src/components/sparky_computer/cubit/sparky_computer_state.dart new file mode 100644 index 00000000..372f1d15 --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_computer/cubit/sparky_computer_state.dart @@ -0,0 +1,8 @@ +// ignore_for_file: public_member_api_docs + +part of 'sparky_computer_cubit.dart'; + +enum SparkyComputerState { + withoutBall, + withBall, +} diff --git a/packages/pinball_components/lib/src/components/sparky_computer.dart b/packages/pinball_components/lib/src/components/sparky_computer/sparky_computer.dart similarity index 62% rename from packages/pinball_components/lib/src/components/sparky_computer.dart rename to packages/pinball_components/lib/src/components/sparky_computer/sparky_computer.dart index 8e2fc905..9025d69d 100644 --- a/packages/pinball_components/lib/src/components/sparky_computer.dart +++ b/packages/pinball_components/lib/src/components/sparky_computer/sparky_computer.dart @@ -2,31 +2,52 @@ 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_components/src/components/sparky_computer/behaviors/behaviors.dart'; import 'package:pinball_flame/pinball_flame.dart'; +export 'cubit/sparky_computer_cubit.dart'; + /// {@template sparky_computer} /// A computer owned by Sparky. /// {@endtemplate} -class SparkyComputer extends Component { +class SparkyComputer extends BodyComponent { /// {@macro sparky_computer} - SparkyComputer() - : super( + SparkyComputer({Iterable? children}) + : bloc = SparkyComputerCubit(), + super( + renderBody: false, children: [ - _ComputerBase(), + SparkyComputerSensorBallContactBehavior() + ..applyTo(['turbo_charge_sensor']), + _ComputerBaseSpriteComponent(), _ComputerTopSpriteComponent(), _ComputerGlowSpriteComponent(), + ...?children, ], ); -} -class _ComputerBase extends BodyComponent with InitialPosition, ZIndex { - _ComputerBase() - : super( - renderBody: false, - children: [_ComputerBaseSpriteComponent()], - ) { - zIndex = ZIndexes.computerBase; + /// Creates a [SparkyComputer] without any children. + /// + /// This can be used for testing [SparkyComputer]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + SparkyComputer.test({ + required this.bloc, + Iterable? children, + }) : super(children: children); + + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final SparkyComputerCubit bloc; + + @override + void onRemove() { + bloc.close(); + super.onRemove(); } List _createFixtureDefs() { @@ -45,30 +66,44 @@ class _ComputerBase extends BodyComponent with InitialPosition, ZIndex { topEdge.vertex2, Vector2(-9.4, -47.1), ); + final turboChargeSensor = PolygonShape() + ..setAsBox( + 1, + 0.1, + Vector2(-13.2, -49.9), + -0.18, + ); return [ FixtureDef(leftEdge), FixtureDef(topEdge), FixtureDef(rightEdge), + FixtureDef( + turboChargeSensor, + isSensor: true, + userData: 'turbo_charge_sensor', + ), ]; } @override Body createBody() { - final bodyDef = BodyDef(position: initialPosition); - final body = world.createBody(bodyDef); + final body = world.createBody(BodyDef()); _createFixtureDefs().forEach(body.createFixture); return body; } } -class _ComputerBaseSpriteComponent extends SpriteComponent with HasGameRef { +class _ComputerBaseSpriteComponent extends SpriteComponent + with HasGameRef, ZIndex { _ComputerBaseSpriteComponent() : super( anchor: Anchor.center, position: Vector2(-12.44, -48.15), - ); + ) { + zIndex = ZIndexes.computerBase; + } @override Future onLoad() async { diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 573fe353..3fffaa88 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flame: ^1.1.1 flame_forge2d: git: - url: https://github.com/flame-engine/flame/ + url: https://github.com/flame-engine/flame path: packages/flame_forge2d/ ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: diff --git a/packages/pinball_components/sandbox/pubspec.lock b/packages/pinball_components/sandbox/pubspec.lock index a22d42c8..0357656d 100644 --- a/packages/pinball_components/sandbox/pubspec.lock +++ b/packages/pinball_components/sandbox/pubspec.lock @@ -112,7 +112,7 @@ packages: path: "packages/flame_forge2d" ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f resolved-ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f - url: "https://github.com/flame-engine/flame/" + url: "https://github.com/flame-engine/flame" source: git version: "0.11.0" flutter: diff --git a/packages/pinball_components/sandbox/pubspec.yaml b/packages/pinball_components/sandbox/pubspec.yaml index d663cb04..791020d0 100644 --- a/packages/pinball_components/sandbox/pubspec.yaml +++ b/packages/pinball_components/sandbox/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flame: ^1.1.1 flame_forge2d: git: - url: https://github.com/flame-engine/flame/ + url: https://github.com/flame-engine/flame path: packages/flame_forge2d/ ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: diff --git a/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/down.png b/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/down.png index eaeb458e..a84d84c2 100644 Binary files a/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/down.png and b/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/down.png differ diff --git a/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/middle.png b/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/middle.png index d8665644..0515f5f5 100644 Binary files a/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/middle.png and b/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/middle.png differ diff --git a/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/up.png b/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/up.png index a584b785..0a2d4674 100644 Binary files a/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/up.png and b/packages/pinball_components/test/src/components/chrome_dino/golden/chrome_dino/up.png differ diff --git a/packages/pinball_components/test/src/components/sparky_computer/behaviors/sparky_computer_sensor_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/sparky_computer/behaviors/sparky_computer_sensor_ball_contact_behavior_test.dart new file mode 100644 index 00000000..d90cc2c9 --- /dev/null +++ b/packages/pinball_components/test/src/components/sparky_computer/behaviors/sparky_computer_sensor_ball_contact_behavior_test.dart @@ -0,0 +1,141 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.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'; +import 'package:pinball_components/src/components/sparky_computer/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockSparkyComputerCubit extends Mock implements SparkyComputerCubit {} + +class _MockBall extends Mock implements Ball {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'SparkyComputerSensorBallContactBehavior', + () { + test('can be instantiated', () { + expect( + SparkyComputerSensorBallContactBehavior(), + isA(), + ); + }); + + group('beginContact', () { + flameTester.test( + 'stops a ball', + (game) async { + final behavior = SparkyComputerSensorBallContactBehavior(); + final bloc = _MockSparkyComputerCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SparkyComputerState.withoutBall, + ); + + final sparkyComputer = SparkyComputer.test( + bloc: bloc, + ); + await sparkyComputer.add(behavior); + await game.ensureAdd(sparkyComputer); + + final ball = _MockBall(); + await behavior.beginContact(ball, _MockContact()); + + verify(ball.stop).called(1); + }, + ); + + flameTester.test( + 'emits onBallEntered when contacts with a ball', + (game) async { + final behavior = SparkyComputerSensorBallContactBehavior(); + final bloc = _MockSparkyComputerCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SparkyComputerState.withoutBall, + ); + + final sparkyComputer = SparkyComputer.test( + bloc: bloc, + ); + await sparkyComputer.add(behavior); + await game.ensureAdd(sparkyComputer); + + await behavior.beginContact(_MockBall(), _MockContact()); + + verify(sparkyComputer.bloc.onBallEntered).called(1); + }, + ); + + flameTester.test( + 'adds TimerComponent when contacts with a ball', + (game) async { + final behavior = SparkyComputerSensorBallContactBehavior(); + final bloc = _MockSparkyComputerCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SparkyComputerState.withoutBall, + ); + + final sparkyComputer = SparkyComputer.test( + bloc: bloc, + ); + await sparkyComputer.add(behavior); + await game.ensureAdd(sparkyComputer); + + await behavior.beginContact(_MockBall(), _MockContact()); + await game.ready(); + + expect( + sparkyComputer.firstChild(), + isA(), + ); + }, + ); + + flameTester.test( + 'TimerComponent resumes ball and calls onBallTurboCharged onTick', + (game) async { + final behavior = SparkyComputerSensorBallContactBehavior(); + final bloc = _MockSparkyComputerCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SparkyComputerState.withoutBall, + ); + + final sparkyComputer = SparkyComputer.test( + bloc: bloc, + ); + await sparkyComputer.add(behavior); + await game.ensureAdd(sparkyComputer); + + final ball = _MockBall(); + await behavior.beginContact(ball, _MockContact()); + await game.ready(); + game.update( + sparkyComputer.firstChild()!.timer.limit, + ); + await game.ready(); + + verify(ball.resume).called(1); + verify(sparkyComputer.bloc.onBallTurboCharged).called(1); + }, + ); + }); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/sparky_computer/cubit/sparky_computer_cubit_test.dart b/packages/pinball_components/test/src/components/sparky_computer/cubit/sparky_computer_cubit_test.dart new file mode 100644 index 00000000..b08b412f --- /dev/null +++ b/packages/pinball_components/test/src/components/sparky_computer/cubit/sparky_computer_cubit_test.dart @@ -0,0 +1,24 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group( + 'SparkyComputerCubit', + () { + blocTest( + 'onBallEntered emits withBall', + build: SparkyComputerCubit.new, + act: (bloc) => bloc.onBallEntered(), + expect: () => [SparkyComputerState.withBall], + ); + + blocTest( + 'onBallTurboCharged emits withoutBall', + build: SparkyComputerCubit.new, + act: (bloc) => bloc.onBallTurboCharged(), + expect: () => [SparkyComputerState.withoutBall], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/sparky_computer/sparky_computer_test.dart b/packages/pinball_components/test/src/components/sparky_computer/sparky_computer_test.dart new file mode 100644 index 00000000..ffb14fd8 --- /dev/null +++ b/packages/pinball_components/test/src/components/sparky_computer/sparky_computer_test.dart @@ -0,0 +1,93 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.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'; +import 'package:pinball_components/src/components/sparky_computer/behaviors/behaviors.dart'; + +import '../../../helpers/helpers.dart'; + +class _MockSparkyComputerCubit extends Mock implements SparkyComputerCubit {} + +void main() { + group('SparkyComputer', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.sparky.computer.base.keyName, + Assets.images.sparky.computer.top.keyName, + Assets.images.sparky.computer.glow.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + flameTester.test('loads correctly', (game) async { + final component = SparkyComputer(); + await game.ensureAdd(component); + expect(game.contains(component), isTrue); + }); + + flameTester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd(SparkyComputer()); + await tester.pump(); + + game.camera + ..followVector2(Vector2(0, -20)) + ..zoom = 7; + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('../golden/sparky-computer.png'), + ); + }, + ); + + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + flameTester.test('closes bloc when removed', (game) async { + final bloc = _MockSparkyComputerCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SparkyComputerState.withoutBall, + ); + when(bloc.close).thenAnswer((_) async {}); + final sparkyComputer = SparkyComputer.test(bloc: bloc); + + await game.ensureAdd(sparkyComputer); + game.remove(sparkyComputer); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final sparkyComputer = SparkyComputer( + children: [component], + ); + await game.ensureAdd(sparkyComputer); + expect(sparkyComputer.children, contains(component)); + }); + + flameTester.test('a SparkyComputerSensorBallContactBehavior', + (game) async { + final sparkyComputer = SparkyComputer(); + await game.ensureAdd(sparkyComputer); + expect( + sparkyComputer.children + .whereType() + .single, + isNotNull, + ); + }); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/sparky_computer_test.dart b/packages/pinball_components/test/src/components/sparky_computer_test.dart deleted file mode 100644 index ffba79b6..00000000 --- a/packages/pinball_components/test/src/components/sparky_computer_test.dart +++ /dev/null @@ -1,45 +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() { - group('SparkyComputer', () { - TestWidgetsFlutterBinding.ensureInitialized(); - final assets = [ - Assets.images.sparky.computer.base.keyName, - Assets.images.sparky.computer.top.keyName, - Assets.images.sparky.computer.glow.keyName, - ]; - final flameTester = FlameTester(() => TestGame(assets)); - - flameTester.test('loads correctly', (game) async { - final component = SparkyComputer(); - await game.ensureAdd(component); - expect(game.contains(component), isTrue); - }); - - flameTester.testGameWidget( - 'renders correctly', - setUp: (game, tester) async { - await game.images.loadAll(assets); - await game.ensureAdd(SparkyComputer()); - await tester.pump(); - - game.camera - ..followVector2(Vector2(0, -20)) - ..zoom = 7; - }, - verify: (game, tester) async { - await expectLater( - find.byGame(), - matchesGoldenFile('golden/sparky-computer.png'), - ); - }, - ); - }); -} diff --git a/packages/pinball_flame/lib/src/canvas/z_canvas_component.dart b/packages/pinball_flame/lib/src/canvas/z_canvas_component.dart index e097f359..e149bf58 100644 --- a/packages/pinball_flame/lib/src/canvas/z_canvas_component.dart +++ b/packages/pinball_flame/lib/src/canvas/z_canvas_component.dart @@ -1,4 +1,5 @@ import 'dart:ui'; +import 'package:collection/collection.dart' as collection; import 'package:flame/components.dart'; import 'package:pinball_flame/src/canvas/canvas_wrapper.dart'; @@ -56,7 +57,14 @@ class _ZCanvas extends CanvasWrapper { final List _zBuffer = []; /// Postpones the rendering of [ZIndex] component and its children. - void buffer(ZIndex component) => _zBuffer.add(component); + void buffer(ZIndex component) { + final lowerBound = collection.lowerBound( + _zBuffer, + component, + compare: (a, b) => a.zIndex.compareTo(b.zIndex), + ); + _zBuffer.insert(lowerBound, component); + } /// Renders all [ZIndex] components and their children. /// @@ -69,8 +77,7 @@ class _ZCanvas extends CanvasWrapper { /// before the second one. /// {@endtemplate} void render() => _zBuffer - ..sort((a, b) => a.zIndex.compareTo(b.zIndex)) - ..whereType().forEach(_render) + ..forEach(_render) ..clear(); void _render(Component component) => component.renderTree(canvas); diff --git a/packages/pinball_flame/pubspec.yaml b/packages/pinball_flame/pubspec.yaml index 89caf5bb..125e7757 100644 --- a/packages/pinball_flame/pubspec.yaml +++ b/packages/pinball_flame/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: flame: ^1.1.1 flame_forge2d: git: - url: https://github.com/flame-engine/flame/ + url: https://github.com/flame-engine/flame path: packages/flame_forge2d/ ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: diff --git a/pubspec.lock b/pubspec.lock index 96f9f2a6..83de1390 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -245,7 +245,7 @@ packages: path: "packages/flame_forge2d" ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f resolved-ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f - url: "https://github.com/flame-engine/flame/" + url: "https://github.com/flame-engine/flame" source: git version: "0.11.0" flame_test: @@ -821,4 +821,4 @@ packages: version: "3.1.0" sdks: dart: ">=2.16.0 <3.0.0" - flutter: ">=2.10.0" + flutter: ">=2.10.5" diff --git a/pubspec.yaml b/pubspec.yaml index dcfa6f3d..1a025d4a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,6 +5,7 @@ publish_to: none environment: sdk: ">=2.16.0 <3.0.0" + flutter: 2.10.5 dependencies: authentication_repository: @@ -18,7 +19,7 @@ dependencies: flame_bloc: ^1.4.0 flame_forge2d: git: - url: https://github.com/flame-engine/flame/ + url: https://github.com/flame-engine/flame path: packages/flame_forge2d/ ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: diff --git a/test/game/behaviors/bonus_noise_behavior_test.dart b/test/game/behaviors/bonus_noise_behavior_test.dart new file mode 100644 index 00000000..5ec37bce --- /dev/null +++ b/test/game/behaviors/bonus_noise_behavior_test.dart @@ -0,0 +1,186 @@ +// ignore_for_file: cascade_invocations + +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/game/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + BonusNoiseBehavior child, { + required PinballPlayer player, + required GameBloc bloc, + }) { + return ensureAdd( + FlameBlocProvider.value( + value: bloc, + children: [ + FlameProvider.value( + player, + children: [ + child, + ], + ), + ], + ), + ); + } +} + +class _MockPinballPlayer extends Mock implements PinballPlayer {} + +class _MockGameBloc extends Mock implements GameBloc {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('BonusNoiseBehavior', () { + late PinballPlayer player; + late GameBloc bloc; + final flameTester = FlameTester(_TestGame.new); + + setUpAll(() { + registerFallbackValue(PinballAudio.google); + }); + + setUp(() { + player = _MockPinballPlayer(); + when(() => player.play(any())).thenAnswer((_) {}); + bloc = _MockGameBloc(); + }); + + flameTester.testGameWidget( + 'plays google sound', + setUp: (game, _) async { + const state = GameState( + totalScore: 0, + roundScore: 0, + multiplier: 1, + rounds: 0, + bonusHistory: [GameBonus.googleWord], + status: GameStatus.playing, + ); + const initialState = GameState.initial(); + whenListen( + bloc, + Stream.fromIterable([initialState, state]), + initialState: initialState, + ); + final behavior = BonusNoiseBehavior(); + await game.pump(behavior, player: player, bloc: bloc); + }, + verify: (_, __) async { + verify(() => player.play(PinballAudio.google)).called(1); + }, + ); + + flameTester.testGameWidget( + 'plays sparky sound', + setUp: (game, _) async { + const state = GameState( + totalScore: 0, + roundScore: 0, + multiplier: 1, + rounds: 0, + bonusHistory: [GameBonus.sparkyTurboCharge], + status: GameStatus.playing, + ); + const initialState = GameState.initial(); + whenListen( + bloc, + Stream.fromIterable([initialState, state]), + initialState: initialState, + ); + final behavior = BonusNoiseBehavior(); + await game.pump(behavior, player: player, bloc: bloc); + }, + verify: (_, __) async { + verify(() => player.play(PinballAudio.sparky)).called(1); + }, + ); + + flameTester.testGameWidget( + 'plays dino chomp sound', + setUp: (game, _) async { + const state = GameState( + totalScore: 0, + roundScore: 0, + multiplier: 1, + rounds: 0, + bonusHistory: [GameBonus.dinoChomp], + status: GameStatus.playing, + ); + const initialState = GameState.initial(); + whenListen( + bloc, + Stream.fromIterable([initialState, state]), + initialState: initialState, + ); + final behavior = BonusNoiseBehavior(); + await game.pump(behavior, player: player, bloc: bloc); + }, + verify: (_, __) async { + // TODO(erickzanardo): Change when the sound is implemented + verifyNever(() => player.play(any())); + }, + ); + + flameTester.testGameWidget( + 'plays android spaceship sound', + setUp: (game, _) async { + const state = GameState( + totalScore: 0, + roundScore: 0, + multiplier: 1, + rounds: 0, + bonusHistory: [GameBonus.androidSpaceship], + status: GameStatus.playing, + ); + const initialState = GameState.initial(); + whenListen( + bloc, + Stream.fromIterable([initialState, state]), + initialState: initialState, + ); + final behavior = BonusNoiseBehavior(); + await game.pump(behavior, player: player, bloc: bloc); + }, + verify: (_, __) async { + // TODO(erickzanardo): Change when the sound is implemented + verifyNever(() => player.play(any())); + }, + ); + + flameTester.testGameWidget( + 'plays dash nest sound', + setUp: (game, _) async { + const state = GameState( + totalScore: 0, + roundScore: 0, + multiplier: 1, + rounds: 0, + bonusHistory: [GameBonus.dashNest], + status: GameStatus.playing, + ); + const initialState = GameState.initial(); + whenListen( + bloc, + Stream.fromIterable([initialState, state]), + initialState: initialState, + ); + final behavior = BonusNoiseBehavior(); + await game.pump(behavior, player: player, bloc: bloc); + }, + verify: (_, __) async { + // TODO(erickzanardo): Change when the sound is implemented + verifyNever(() => player.play(any())); + }, + ); + }); +} diff --git a/test/game/components/controlled_ball_test.dart b/test/game/components/controlled_ball_test.dart deleted file mode 100644 index 95451515..00000000 --- a/test/game/components/controlled_ball_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -// 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/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_theme/pinball_theme.dart' as theme; - -class _TestGame extends Forge2DGame { - @override - Future onLoad() async { - images.prefix = ''; - await images.load(theme.Assets.images.dash.ball.keyName); - } - - Future pump(Ball child, {required GameBloc gameBloc}) async { - await ensureAdd( - FlameBlocProvider.value( - value: gameBloc, - children: [child], - ), - ); - } -} - -class _MockGameBloc extends Mock implements GameBloc {} - -class _MockBall extends Mock implements Ball {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('BallController', () { - late Ball ball; - late GameBloc gameBloc; - - setUp(() { - ball = Ball(); - gameBloc = _MockGameBloc(); - }); - - final flameBlocTester = FlameTester(_TestGame.new); - - test('can be instantiated', () { - expect( - BallController(_MockBall()), - isA(), - ); - }); - - flameBlocTester.testGameWidget( - 'turboCharge adds TurboChargeActivated', - setUp: (game, tester) async { - await game.onLoad(); - - final controller = BallController(ball); - await ball.add(controller); - await game.pump(ball, gameBloc: gameBloc); - - await controller.turboCharge(); - }, - verify: (game, tester) async { - verify(() => gameBloc.add(const SparkyTurboChargeActivated())) - .called(1); - }, - ); - }); -} diff --git a/test/game/components/sparky_scorch/behaviors/sparky_computer_bonus_behavior_test.dart b/test/game/components/sparky_scorch/behaviors/sparky_computer_bonus_behavior_test.dart new file mode 100644 index 00000000..fbfeef0b --- /dev/null +++ b/test/game/components/sparky_scorch/behaviors/sparky_computer_bonus_behavior_test.dart @@ -0,0 +1,86 @@ +// 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/game/components/sparky_scorch/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class _TestGame extends Forge2DGame { + @override + Future onLoad() async { + images.prefix = ''; + await images.loadAll([ + Assets.images.sparky.computer.top.keyName, + Assets.images.sparky.computer.base.keyName, + Assets.images.sparky.computer.glow.keyName, + Assets.images.sparky.animatronic.keyName, + Assets.images.sparky.bumper.a.lit.keyName, + Assets.images.sparky.bumper.a.dimmed.keyName, + Assets.images.sparky.bumper.b.lit.keyName, + Assets.images.sparky.bumper.b.dimmed.keyName, + Assets.images.sparky.bumper.c.lit.keyName, + Assets.images.sparky.bumper.c.dimmed.keyName, + ]); + } + + Future pump( + SparkyScorch child, { + required GameBloc gameBloc, + }) async { + // Not needed once https://github.com/flame-engine/flame/issues/1607 + // is fixed + await onLoad(); + await ensureAdd( + FlameBlocProvider.value( + value: gameBloc, + children: [child], + ), + ); + } +} + +class _MockGameBloc extends Mock implements GameBloc {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SparkyComputerBonusBehavior', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = _MockGameBloc(); + }); + + final flameTester = FlameTester(_TestGame.new); + + flameTester.testGameWidget( + 'adds GameBonus.sparkyTurboCharge to the game and plays animatronic ' + 'when SparkyComputerState.withBall is emitted', + setUp: (game, tester) async { + final behavior = SparkyComputerBonusBehavior(); + final parent = SparkyScorch.test(); + final sparkyComputer = SparkyComputer(); + final animatronic = SparkyAnimatronic(); + + await parent.addAll([ + sparkyComputer, + animatronic, + ]); + await game.pump(parent, gameBloc: gameBloc); + await parent.ensureAdd(behavior); + + sparkyComputer.bloc.onBallEntered(); + await tester.pump(); + + verify( + () => gameBloc.add(const BonusActivated(GameBonus.sparkyTurboCharge)), + ).called(1); + expect(animatronic.playing, isTrue); + }, + ); + }); +} diff --git a/test/game/components/sparky_scorch_test.dart b/test/game/components/sparky_scorch/sparky_scorch_test.dart similarity index 58% rename from test/game/components/sparky_scorch_test.dart rename to test/game/components/sparky_scorch/sparky_scorch_test.dart index 92a3ab01..0cd7b806 100644 --- a/test/game/components/sparky_scorch_test.dart +++ b/test/game/components/sparky_scorch/sparky_scorch_test.dart @@ -1,10 +1,11 @@ // 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/game/behaviors/behaviors.dart'; +import 'package:pinball/game/components/sparky_scorch/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -25,13 +26,16 @@ class _TestGame extends Forge2DGame { Assets.images.sparky.bumper.c.dimmed.keyName, ]); } -} - -class _MockControlledBall extends Mock implements ControlledBall {} - -class _MockBallController extends Mock implements BallController {} -class _MockContact extends Mock implements Contact {} + Future pump(SparkyScorch child) async { + await ensureAdd( + FlameBlocProvider.value( + value: GameBloc(), + children: [child], + ), + ); + } +} void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -41,15 +45,18 @@ void main() { group('SparkyScorch', () { flameTester.test('loads correctly', (game) async { final component = SparkyScorch(); - await game.ensureAdd(component); - expect(game.contains(component), isTrue); + await game.pump(component); + expect( + game.descendants().whereType().length, + equals(1), + ); }); group('loads', () { flameTester.test( 'a SparkyComputer', (game) async { - await game.ensureAdd(SparkyScorch()); + await game.pump(SparkyScorch()); expect( game.descendants().whereType().length, equals(1), @@ -60,7 +67,7 @@ void main() { flameTester.test( 'a SparkyAnimatronic', (game) async { - await game.ensureAdd(SparkyScorch()); + await game.pump(SparkyScorch()); expect( game.descendants().whereType().length, equals(1), @@ -71,7 +78,7 @@ void main() { flameTester.test( 'three SparkyBumper', (game) async { - await game.ensureAdd(SparkyScorch()); + await game.pump(SparkyScorch()); expect( game.descendants().whereType().length, equals(3), @@ -82,7 +89,7 @@ void main() { flameTester.test( 'three SparkyBumpers with BumperNoiseBehavior', (game) async { - await game.ensureAdd(SparkyScorch()); + await game.pump(SparkyScorch()); final bumpers = game.descendants().whereType(); for (final bumper in bumpers) { expect( @@ -93,41 +100,30 @@ void main() { }, ); }); - }); - - group('SparkyComputerSensor', () { - flameTester.test('calls turboCharge', (game) async { - final sensor = SparkyComputerSensor(); - final ball = _MockControlledBall(); - final controller = _MockBallController(); - when(() => ball.controller).thenReturn(controller); - when(controller.turboCharge).thenAnswer((_) async {}); - - await game.ensureAddAll([ - sensor, - SparkyAnimatronic(), - ]); - sensor.beginContact(ball, _MockContact()); - - verify(() => ball.controller.turboCharge()).called(1); - }); + group('adds', () { + flameTester.test( + 'ScoringContactBehavior to SparkyComputer', + (game) async { + await game.pump(SparkyScorch()); - flameTester.test('plays SparkyAnimatronic', (game) async { - final sensor = SparkyComputerSensor(); - final sparkyAnimatronic = SparkyAnimatronic(); - final ball = _MockControlledBall(); - final controller = _MockBallController(); - when(() => ball.controller).thenReturn(controller); - when(controller.turboCharge).thenAnswer((_) async {}); - await game.ensureAddAll([ - sensor, - sparkyAnimatronic, - ]); + final sparkyComputer = + game.descendants().whereType().single; + expect( + sparkyComputer.firstChild(), + isNotNull, + ); + }, + ); - expect(sparkyAnimatronic.playing, isFalse); - sensor.beginContact(ball, _MockContact()); - expect(sparkyAnimatronic.playing, isTrue); + flameTester.test('a SparkyComputerBonusBehavior', (game) async { + final sparkyScorch = SparkyScorch(); + await game.pump(sparkyScorch); + expect( + sparkyScorch.children.whereType().single, + isNotNull, + ); + }); }); }); } diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 43e3560a..2840e24f 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -426,14 +426,12 @@ void main() { when(() => tapUpEvent.raw).thenReturn(raw); await game.ready(); - final previousBalls = - game.descendants().whereType().toList(); + final previousBalls = game.descendants().whereType().toList(); game.onTapUp(0, tapUpEvent); await game.ready(); - final currentBalls = - game.descendants().whereType().toList(); + final currentBalls = game.descendants().whereType().toList(); expect( currentBalls.length, @@ -492,14 +490,13 @@ void main() { game.lineEnd = endPosition; await game.ready(); - final previousBalls = - game.descendants().whereType().toList(); + final previousBalls = game.descendants().whereType().toList(); game.onPanEnd(_MockDragEndInfo()); await game.ready(); expect( - game.descendants().whereType().length, + game.descendants().whereType().length, equals(previousBalls.length + 1), ); }, diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index df66ba6d..479bdcc5 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -1,5 +1,7 @@ // ignore_for_file: prefer_const_constructors +import 'dart:ui'; + import 'package:bloc_test/bloc_test.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; @@ -8,7 +10,9 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/assets_manager/assets_manager.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/gen/gen.dart'; import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/more_information/more_information.dart'; import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; @@ -79,6 +83,7 @@ void main() { await tester.pumpApp( PinballGamePage(), characterThemeCubit: characterThemeCubit, + gameBloc: gameBloc, ); expect(find.byType(PinballGameView), findsOneWidget); @@ -167,6 +172,7 @@ void main() { ), ), characterThemeCubit: characterThemeCubit, + gameBloc: gameBloc, ); await tester.tap(find.text('Tap me')); @@ -290,5 +296,90 @@ void main() { findsNothing, ); }); + + testWidgets('keep focus on game when mouse hovers over it', (tester) async { + final startGameState = StartGameState.initial().copyWith( + status: StartGameStatus.play, + ); + final gameState = GameState.initial().copyWith( + status: GameStatus.gameOver, + ); + + whenListen( + startGameBloc, + Stream.value(startGameState), + initialState: startGameState, + ); + whenListen( + gameBloc, + Stream.value(gameState), + initialState: gameState, + ); + await tester.pumpApp( + PinballGameView(game: game), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + + game.focusNode.unfocus(); + await tester.pump(); + + expect(game.focusNode.hasFocus, isFalse); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo((game.size / 2).toOffset()); + await tester.pump(); + + expect(game.focusNode.hasFocus, isTrue); + }); + + group('info icon', () { + testWidgets('renders on game over', (tester) async { + final gameState = GameState.initial().copyWith( + status: GameStatus.gameOver, + ); + + whenListen( + gameBloc, + Stream.value(gameState), + initialState: gameState, + ); + + await tester.pumpApp( + PinballGameView(game: game), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + + expect( + find.image(Assets.images.linkBox.infoIcon), + findsOneWidget, + ); + }); + + testWidgets('opens MoreInformationDialog when tapped', (tester) async { + final gameState = GameState.initial().copyWith( + status: GameStatus.gameOver, + ); + whenListen( + gameBloc, + Stream.value(gameState), + initialState: gameState, + ); + await tester.pumpApp( + PinballGameView(game: game), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + await tester.tap(find.byType(IconButton)); + await tester.pump(); + expect( + find.byType(MoreInformationDialog), + findsOneWidget, + ); + }); + }); }); } diff --git a/test/footer/footer_test.dart b/test/more_information/more_information_dialog_test.dart similarity index 60% rename from test/footer/footer_test.dart rename to test/more_information/more_information_dialog_test.dart index 8f683cbf..f87ec84c 100644 --- a/test/footer/footer_test.dart +++ b/test/more_information/more_information_dialog_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:pinball/footer/footer.dart'; +import 'package:pinball/more_information/more_information.dart'; import 'package:pinball_ui/pinball_ui.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; @@ -28,15 +28,34 @@ class _MockUrlLauncher extends Mock implements UrlLauncherPlatform {} void main() { - group('Footer', () { + group('MoreInformationDialog', () { late UrlLauncherPlatform urlLauncher; setUp(() async { urlLauncher = _MockUrlLauncher(); UrlLauncherPlatform.instance = urlLauncher; }); + + group('showMoreInformationDialog', () { + testWidgets('inflates the dialog', (tester) async { + await tester.pumpApp( + Builder( + builder: (context) { + return TextButton( + onPressed: () => showMoreInformationDialog(context), + child: const Text('test'), + ); + }, + ), + ); + await tester.tap(find.text('test')); + await tester.pump(); + expect(find.byType(MoreInformationDialog), findsOneWidget); + }); + }); + testWidgets('renders "Made with..." and "Google I/O"', (tester) async { - await tester.pumpApp(const Footer()); + await tester.pumpApp(const MoreInformationDialog()); expect(find.text('Google I/O'), findsOneWidget); expect( find.byWidgetPredicate( @@ -63,7 +82,7 @@ void main() { headers: any(named: 'headers'), ), ).thenAnswer((_) async => true); - await tester.pumpApp(const Footer()); + await tester.pumpApp(const MoreInformationDialog()); final flutterTextFinder = find.byWidgetPredicate( (widget) => widget is RichText && _tapTextSpan(widget, 'Flutter'), ); @@ -98,7 +117,7 @@ void main() { headers: any(named: 'headers'), ), ).thenAnswer((_) async => true); - await tester.pumpApp(const Footer()); + await tester.pumpApp(const MoreInformationDialog()); final firebaseTextFinder = find.byWidgetPredicate( (widget) => widget is RichText && _tapTextSpan(widget, 'Firebase'), ); @@ -117,5 +136,50 @@ void main() { ); }, ); + + { + 'Open Source Code': 'https://github.com/VGVentures/pinball', + 'Google I/O': 'https://events.google.com/io/', + 'Flutter Games': 'http://flutter.dev/games', + 'How it’s made': + 'https://medium.com/flutter/i-o-pinball-powered-by-flutter-and-firebase-d22423f3f5d', + 'Terms of Service': 'https://policies.google.com/terms', + 'Privacy Policy': 'https://policies.google.com/privacy', + }.forEach((text, link) { + testWidgets( + 'tapping on "$text" opens the link - $link', + (tester) async { + when(() => urlLauncher.canLaunch(any())) + .thenAnswer((_) async => true); + when( + () => urlLauncher.launch( + link, + useSafariVC: any(named: 'useSafariVC'), + useWebView: any(named: 'useWebView'), + enableJavaScript: any(named: 'enableJavaScript'), + enableDomStorage: any(named: 'enableDomStorage'), + universalLinksOnly: any(named: 'universalLinksOnly'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => true); + + await tester.pumpApp(const MoreInformationDialog()); + await tester.tap(find.text(text)); + await tester.pumpAndSettle(); + + verify( + () => urlLauncher.launch( + link, + useSafariVC: any(named: 'useSafariVC'), + useWebView: any(named: 'useWebView'), + enableJavaScript: any(named: 'enableJavaScript'), + enableDomStorage: any(named: 'enableDomStorage'), + universalLinksOnly: any(named: 'universalLinksOnly'), + headers: any(named: 'headers'), + ), + ); + }, + ); + }); }); }