diff --git a/.github/workflows/geometry.yaml b/.github/workflows/geometry.yaml index 8bf55107..ccd41914 100644 --- a/.github/workflows/geometry.yaml +++ b/.github/workflows/geometry.yaml @@ -1,5 +1,9 @@ name: geometry +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: paths: diff --git a/.github/workflows/leaderboard_repository.yaml b/.github/workflows/leaderboard_repository.yaml index 6eddf283..327f70b3 100644 --- a/.github/workflows/leaderboard_repository.yaml +++ b/.github/workflows/leaderboard_repository.yaml @@ -1,5 +1,9 @@ name: leaderboard_repository +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: paths: diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index f3e3fd99..8ed906a2 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,5 +1,9 @@ name: pinball +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: [pull_request, push] jobs: diff --git a/.github/workflows/pinball_audio.yaml b/.github/workflows/pinball_audio.yaml index 7a43413a..6ba3adde 100644 --- a/.github/workflows/pinball_audio.yaml +++ b/.github/workflows/pinball_audio.yaml @@ -1,5 +1,9 @@ name: pinball_audio +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: paths: diff --git a/.github/workflows/pinball_components.yaml b/.github/workflows/pinball_components.yaml index d75553f5..19f13044 100644 --- a/.github/workflows/pinball_components.yaml +++ b/.github/workflows/pinball_components.yaml @@ -1,5 +1,9 @@ name: pinball_components +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: paths: diff --git a/.github/workflows/pinball_flame.yaml b/.github/workflows/pinball_flame.yaml index 2263bb5a..297b792e 100644 --- a/.github/workflows/pinball_flame.yaml +++ b/.github/workflows/pinball_flame.yaml @@ -1,5 +1,9 @@ name: pinball_flame +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: paths: diff --git a/.github/workflows/pinball_theme.yaml b/.github/workflows/pinball_theme.yaml index 83206de5..15280761 100644 --- a/.github/workflows/pinball_theme.yaml +++ b/.github/workflows/pinball_theme.yaml @@ -1,5 +1,9 @@ name: pinball_theme +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: paths: diff --git a/assets/images/bonus_animation/android.png b/assets/images/bonus_animation/android_spaceship.png similarity index 100% rename from assets/images/bonus_animation/android.png rename to assets/images/bonus_animation/android_spaceship.png diff --git a/assets/images/bonus_animation/dino.png b/assets/images/bonus_animation/dino_chomp.png similarity index 100% rename from assets/images/bonus_animation/dino.png rename to assets/images/bonus_animation/dino_chomp.png diff --git a/assets/images/bonus_animation/google.png b/assets/images/bonus_animation/google_word.png similarity index 100% rename from assets/images/bonus_animation/google.png rename to assets/images/bonus_animation/google_word.png diff --git a/assets/images/score/mini_score_background.png b/assets/images/score/mini_score_background.png new file mode 100644 index 00000000..781f7349 Binary files /dev/null and b/assets/images/score/mini_score_background.png differ diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 2780b608..97cfec9b 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -13,7 +13,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_audio/pinball_audio.dart'; class App extends StatelessWidget { @@ -36,7 +36,7 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _pinballAudio), ], child: BlocProvider( - create: (context) => ThemeCubit(), + create: (context) => CharacterThemeCubit(), child: const MaterialApp( title: 'I/O Pinball', localizationsDelegates: [ diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index 4ba63092..49f40d1f 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -1,5 +1,5 @@ // ignore_for_file: public_member_api_docs - +import 'dart:math' as math; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; @@ -9,19 +9,41 @@ part 'game_state.dart'; class GameBloc extends Bloc { GameBloc() : super(const GameState.initial()) { - on(_onBallLost); + on(_onRoundLost); on(_onScored); + on(_onIncreasedMultiplier); on(_onBonusActivated); on(_onSparkyTurboChargeActivated); } - void _onBallLost(BallLost event, Emitter emit) { - emit(state.copyWith(balls: state.balls - 1)); + void _onRoundLost(RoundLost event, Emitter emit) { + final score = state.score * state.multiplier; + final roundsLeft = math.max(state.rounds - 1, 0); + + emit( + state.copyWith( + score: score, + multiplier: 1, + rounds: roundsLeft, + ), + ); } void _onScored(Scored event, Emitter emit) { if (!state.isGameOver) { - emit(state.copyWith(score: state.score + event.points)); + emit( + state.copyWith(score: state.score + event.points), + ); + } + } + + void _onIncreasedMultiplier(MultiplierIncreased event, Emitter emit) { + if (!state.isGameOver) { + emit( + state.copyWith( + multiplier: math.min(state.multiplier + 1, 6), + ), + ); } } diff --git a/lib/game/bloc/game_event.dart b/lib/game/bloc/game_event.dart index bbb89028..c81ce526 100644 --- a/lib/game/bloc/game_event.dart +++ b/lib/game/bloc/game_event.dart @@ -7,12 +7,12 @@ abstract class GameEvent extends Equatable { const GameEvent(); } -/// {@template ball_lost_game_event} -/// Event added when a user drops a ball off the screen. +/// {@template round_lost_game_event} +/// Event added when a user drops all balls off the screen and loses a round. /// {@endtemplate} -class BallLost extends GameEvent { - /// {@macro ball_lost_game_event} - const BallLost(); +class RoundLost extends GameEvent { + /// {@macro round_lost_game_event} + const RoundLost(); @override List get props => []; @@ -48,3 +48,14 @@ class SparkyTurboChargeActivated extends GameEvent { @override List get props => []; } + +/// {@template multiplier_increased_game_event} +/// Added when a multiplier is gained. +/// {@endtemplate} +class MultiplierIncreased extends GameEvent { + /// {@macro multiplier_increased_game_event} + const MultiplierIncreased(); + + @override + List get props => []; +} diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index c57eedb4..4ce9042d 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -12,6 +12,12 @@ enum GameBonus { /// Bonus achieved when a ball enters Sparky's computer. sparkyTurboCharge, + + /// Bonus achieved when the ball goes in the dino mouth. + dinoChomp, + + /// Bonus achieved when a ball enters the android spaceship. + androidSpaceship, } /// {@template game_state} @@ -21,34 +27,42 @@ class GameState extends Equatable { /// {@macro game_state} const GameState({ required this.score, - required this.balls, + required this.multiplier, + required this.rounds, required this.bonusHistory, }) : assert(score >= 0, "Score can't be negative"), - assert(balls >= 0, "Number of balls can't be negative"); + assert(multiplier > 0, 'Multiplier must be greater than zero'), + assert(rounds >= 0, "Number of rounds can't be negative"); const GameState.initial() : score = 0, - balls = 3, + multiplier = 1, + rounds = 3, bonusHistory = const []; /// The current score of the game. final int score; - /// The number of balls left in the game. + /// The current multiplier for the score. + final int multiplier; + + /// The number of rounds left in the game. /// - /// When the number of balls is 0, the game is over. - final int balls; + /// When the number of rounds is 0, the game is over. + final int rounds; /// Holds the history of all the [GameBonus]es earned by the player during a /// PinballGame. final List bonusHistory; /// Determines when the game is over. - bool get isGameOver => balls == 0; + bool get isGameOver => rounds == 0; GameState copyWith({ int? score, + int? multiplier, int? balls, + int? rounds, List? bonusHistory, }) { assert( @@ -58,7 +72,8 @@ class GameState extends Equatable { return GameState( score: score ?? this.score, - balls: balls ?? this.balls, + multiplier: multiplier ?? this.multiplier, + rounds: rounds ?? this.rounds, bonusHistory: bonusHistory ?? this.bonusHistory, ); } @@ -66,7 +81,8 @@ class GameState extends Equatable { @override List get props => [ score, - balls, + multiplier, + rounds, bonusHistory, ]; } diff --git a/lib/game/components/alien_zone.dart b/lib/game/components/alien_zone.dart deleted file mode 100644 index 720c1180..00000000 --- a/lib/game/components/alien_zone.dart +++ /dev/null @@ -1,60 +0,0 @@ -// 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/game.dart'; -import 'package:pinball_components/pinball_components.dart'; - -/// {@template alien_zone} -/// Area positioned below [Spaceship] where the [Ball] -/// can bounce off [AlienBumper]s. -/// -/// When a [Ball] hits an [AlienBumper], the bumper animates. -/// {@endtemplate} -class AlienZone extends Component with HasGameRef { - /// {@macro alien_zone} - AlienZone(); - - @override - Future onLoad() async { - await super.onLoad(); - - gameRef.addContactCallback(AlienBumperBallContactCallback()); - - final lowerBumper = _AlienBumper.a() - ..initialPosition = Vector2(-32.52, -9.1); - final upperBumper = _AlienBumper.b() - ..initialPosition = Vector2(-22.89, -17.35); - - await addAll([ - lowerBumper, - upperBumper, - ]); - } -} - -// TODO(alestiago): Revisit ScorePoints logic once the FlameForge2D -// ContactCallback process is enhanced. -class _AlienBumper extends AlienBumper with ScorePoints { - _AlienBumper.a() : super.a(); - - _AlienBumper.b() : super.b(); - - @override - int get points => 20; -} - -/// Listens when a [Ball] bounces against an [AlienBumper]. -@visibleForTesting -class AlienBumperBallContactCallback - extends ContactCallback { - @override - void begin( - AlienBumper alienBumper, - Ball _, - Contact __, - ) { - alienBumper.animate(); - } -} diff --git a/lib/game/components/android_acres.dart b/lib/game/components/android_acres.dart new file mode 100644 index 00000000..752f68f9 --- /dev/null +++ b/lib/game/components/android_acres.dart @@ -0,0 +1,34 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template android_acres} +/// Area positioned on the left side of the board containing the [Spaceship], +/// [SpaceshipRamp], [SpaceshipRail], and [AndroidBumper]s. +/// {@endtemplate} +class AndroidAcres extends Blueprint { + /// {@macro android_acres} + AndroidAcres() + : super( + components: [ + AndroidBumper.a( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(-32.52, -9.1), + AndroidBumper.b( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(-22.89, -17.35), + ], + blueprints: [ + SpaceshipRamp(), + Spaceship(position: Vector2(-26.5, -28.5)), + SpaceshipRail(), + ], + ); +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 7d4b23f7..37de1948 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,13 +1,13 @@ -export 'alien_zone.dart'; +export 'android_acres.dart'; export 'board.dart'; export 'camera_controller.dart'; export 'controlled_ball.dart'; export 'controlled_flipper.dart'; export 'controlled_plunger.dart'; -export 'flutter_forest.dart'; +export 'flutter_forest/flutter_forest.dart'; export 'game_flow_controller.dart'; -export 'google_word.dart'; +export 'google_word/google_word.dart'; export 'launcher.dart'; -export 'score_points.dart'; +export 'scoring_behavior.dart'; export 'sparky_fire_zone.dart'; export 'wall.dart'; diff --git a/lib/game/components/controlled_ball.dart b/lib/game/components/controlled_ball.dart index 4f089a7c..e76aabe1 100644 --- a/lib/game/components/controlled_ball.dart +++ b/lib/game/components/controlled_ball.dart @@ -1,5 +1,4 @@ import 'package:flame/components.dart'; -import 'package:flame_forge2d/forge2d_game.dart'; import 'package:flutter/material.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -8,15 +7,15 @@ 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]. - /// - /// When a launched [Ball] is lost, it will decrease the [GameState.balls] - /// count, and a new [Ball] is spawned. ControlledBall.launch({ - required PinballTheme theme, - }) : super(baseColor: theme.characterTheme.ballColor) { + required CharacterTheme characterTheme, + }) : super(baseColor: characterTheme.ballColor) { controller = BallController(this); priority = RenderPriority.ballOnLaunchRamp; layer = Layer.launcher; @@ -24,19 +23,17 @@ class ControlledBall extends Ball with Controls { /// {@template bonus_ball} /// {@macro controlled_ball} - /// - /// When a bonus [Ball] is lost, the [GameState.balls] doesn't change. /// {@endtemplate} ControlledBall.bonus({ - required PinballTheme theme, - }) : super(baseColor: theme.characterTheme.ballColor) { + required CharacterTheme characterTheme, + }) : super(baseColor: characterTheme.ballColor) { controller = BallController(this); priority = RenderPriority.ballOnBoard; } /// [Ball] used in [DebugPinballGame]. ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) { - controller = DebugBallController(this); + controller = BallController(this); priority = RenderPriority.ballOnBoard; } } @@ -49,10 +46,8 @@ class BallController extends ComponentController /// {@macro ball_controller} BallController(Ball ball) : super(ball); - /// Removes the [Ball] from a [PinballGame]. - /// - /// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into - /// a [BottomWall]. + /// Event triggered when the ball is lost. + // TODO(alestiago): Refactor using behaviors. void lost() { component.shouldRemove = true; } @@ -76,15 +71,9 @@ class BallController extends ComponentController @override void onRemove() { super.onRemove(); - gameRef.read().add(const BallLost()); + final noBallsLeft = gameRef.descendants().whereType().isEmpty; + if (noBallsLeft) { + gameRef.read().add(const RoundLost()); + } } } - -/// {@macro ball_controller} -class DebugBallController extends BallController { - /// {@macro ball_controller} - DebugBallController(Ball component) : super(component); - - @override - void onRemove() {} -} diff --git a/lib/game/components/flutter_forest.dart b/lib/game/components/flutter_forest.dart deleted file mode 100644 index 9c8ab309..00000000 --- a/lib/game/components/flutter_forest.dart +++ /dev/null @@ -1,102 +0,0 @@ -// ignore_for_file: avoid_renaming_method_parameters - -import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template flutter_forest} -/// Area positioned at the top right of the [Board] where the [Ball] -/// can bounce off [DashNestBumper]s. -/// -/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest] -/// is awarded, and the [DashNestBumper.main] releases a new [Ball]. -/// {@endtemplate} -class FlutterForest extends Component - with Controls<_FlutterForestController>, HasGameRef { - /// {@macro flutter_forest} - FlutterForest() { - controller = _FlutterForestController(this); - } - - @override - Future onLoad() async { - await super.onLoad(); - gameRef.addContactCallback(_DashNestBumperBallContactCallback()); - - final signpost = Signpost()..initialPosition = Vector2(8.35, -58.3); - - final bigNest = _DashNestBumper.main() - ..initialPosition = Vector2(18.55, -59.35); - final smallLeftNest = _DashNestBumper.a() - ..initialPosition = Vector2(8.95, -51.95); - final smallRightNest = _DashNestBumper.b() - ..initialPosition = Vector2(23.3, -46.75); - final dashAnimatronic = DashAnimatronic()..position = Vector2(20, -66); - - await addAll([ - signpost, - smallLeftNest, - smallRightNest, - bigNest, - dashAnimatronic, - ]); - } -} - -class _FlutterForestController extends ComponentController - with HasGameRef { - _FlutterForestController(FlutterForest flutterForest) : super(flutterForest); - - final _activatedBumpers = {}; - - void activateBumper(DashNestBumper dashNestBumper) { - if (!_activatedBumpers.add(dashNestBumper)) return; - - dashNestBumper.activate(); - - final activatedBonus = _activatedBumpers.length == 3; - if (activatedBonus) { - _addBonusBall(); - - gameRef.read().add(const BonusActivated(GameBonus.dashNest)); - _activatedBumpers - ..forEach((bumper) => bumper.deactivate()) - ..clear(); - - component.firstChild()?.playing = true; - } - } - - Future _addBonusBall() async { - await gameRef.add( - ControlledBall.bonus(theme: gameRef.theme) - ..initialPosition = Vector2(17.2, -52.7), - ); - } -} - -// TODO(alestiago): Revisit ScorePoints logic once the FlameForge2D -// ContactCallback process is enhanced. -class _DashNestBumper extends DashNestBumper with ScorePoints { - _DashNestBumper.main() : super.main(); - - _DashNestBumper.a() : super.a(); - - _DashNestBumper.b() : super.b(); - - @override - int get points => 20; -} - -class _DashNestBumperBallContactCallback - extends ContactCallback { - @override - void begin(DashNestBumper dashNestBumper, _, __) { - final parent = dashNestBumper.parent; - if (parent is FlutterForest) { - parent.controller.activateBumper(dashNestBumper); - } - } -} diff --git a/lib/game/components/flutter_forest/behaviors/behaviors.dart b/lib/game/components/flutter_forest/behaviors/behaviors.dart new file mode 100644 index 00000000..c0f39810 --- /dev/null +++ b/lib/game/components/flutter_forest/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'flutter_forest_bonus_behavior.dart'; 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 new file mode 100644 index 00000000..949fead1 --- /dev/null +++ b/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart @@ -0,0 +1,41 @@ +import 'package:flame/components.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest] +/// is awarded, and the [DashNestBumper.main] releases a new [Ball]. +class FlutterForestBonusBehavior extends Component + with ParentIsA, HasGameRef { + @override + void onMount() { + super.onMount(); + + final bumpers = parent.children.whereType(); + for (final bumper in bumpers) { + // TODO(alestiago): Refactor subscription management once the following is + // merged: + // https://github.com/flame-engine/flame/pull/1538 + bumper.bloc.stream.listen((state) { + final achievedBonus = bumpers.every( + (bumper) => bumper.bloc.state == DashNestBumperState.active, + ); + + if (achievedBonus) { + gameRef + .read() + .add(const BonusActivated(GameBonus.dashNest)); + gameRef.add( + ControlledBall.bonus(characterTheme: gameRef.characterTheme) + ..initialPosition = Vector2(17.2, -52.7), + ); + parent.firstChild()?.playing = true; + + for (final bumper in bumpers) { + bumper.bloc.onReset(); + } + } + }); + } + } +} diff --git a/lib/game/components/flutter_forest/flutter_forest.dart b/lib/game/components/flutter_forest/flutter_forest.dart new file mode 100644 index 00000000..02483159 --- /dev/null +++ b/lib/game/components/flutter_forest/flutter_forest.dart @@ -0,0 +1,49 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template flutter_forest} +/// Area positioned at the top right of the [Board] where the [Ball] can bounce +/// off [DashNestBumper]s. +/// {@endtemplate} +class FlutterForest extends Component { + /// {@macro flutter_forest} + FlutterForest() + : super( + priority: RenderPriority.flutterForest, + children: [ + Signpost( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(8.35, -58.3), + DashNestBumper.main( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(18.55, -59.35), + DashNestBumper.a( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(8.95, -51.95), + DashNestBumper.b( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(23.3, -46.75), + DashAnimatronic()..position = Vector2(20, -66), + FlutterForestBonusBehavior(), + ], + ); + + /// Creates a [FlutterForest] without any children. + /// + /// This can be used for testing [FlutterForest]'s behaviors in isolation. + @visibleForTesting + FlutterForest.test(); +} diff --git a/lib/game/components/game_flow_controller.dart b/lib/game/components/game_flow_controller.dart index 77afebe6..48dd5518 100644 --- a/lib/game/components/game_flow_controller.dart +++ b/lib/game/components/game_flow_controller.dart @@ -32,8 +32,7 @@ class GameFlowController extends ComponentController // next page component.firstChild()?.gameOverMode( score: state?.score ?? 0, - characterIconPath: - component.theme.characterTheme.leaderboardIcon.keyName, + characterIconPath: component.characterTheme.leaderboardIcon.keyName, ); component.firstChild()?.focusOnBackboard(); } diff --git a/lib/game/components/google_word.dart b/lib/game/components/google_word.dart deleted file mode 100644 index 34609c64..00000000 --- a/lib/game/components/google_word.dart +++ /dev/null @@ -1,83 +0,0 @@ -// ignore_for_file: avoid_renaming_method_parameters - -import 'dart:async'; - -import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template google_word} -/// Loads all [GoogleLetter]s to compose a [GoogleWord]. -/// {@endtemplate} -class GoogleWord extends Component - with HasGameRef, Controls<_GoogleWordController> { - /// {@macro google_word} - GoogleWord({ - required Vector2 position, - }) : _position = position { - controller = _GoogleWordController(this); - } - - final Vector2 _position; - - @override - Future onLoad() async { - await super.onLoad(); - gameRef.addContactCallback(_GoogleLetterBallContactCallback()); - - final offsets = [ - Vector2(-12.92, 1.82), - Vector2(-8.33, -0.65), - Vector2(-2.88, -1.75), - Vector2(2.88, -1.75), - Vector2(8.33, -0.65), - Vector2(12.92, 1.82), - ]; - - final letters = []; - for (var index = 0; index < offsets.length; index++) { - letters.add( - GoogleLetter(index)..initialPosition = _position + offsets[index], - ); - } - - await addAll(letters); - } -} - -class _GoogleWordController extends ComponentController - with HasGameRef { - _GoogleWordController(GoogleWord googleWord) : super(googleWord); - - final _activatedLetters = {}; - - void activate(GoogleLetter googleLetter) { - if (!_activatedLetters.add(googleLetter)) return; - - googleLetter.activate(); - - final activatedBonus = _activatedLetters.length == 6; - if (activatedBonus) { - gameRef.audio.googleBonus(); - gameRef.read().add(const BonusActivated(GameBonus.googleWord)); - component.children.whereType().forEach( - (letter) => letter.deactivate(), - ); - _activatedLetters.clear(); - } - } -} - -/// Activates a [GoogleLetter] when it contacts with a [Ball]. -class _GoogleLetterBallContactCallback - extends ContactCallback { - @override - void begin(GoogleLetter googleLetter, _, __) { - final parent = googleLetter.parent; - if (parent is GoogleWord) { - parent.controller.activate(googleLetter); - } - } -} diff --git a/lib/game/components/google_word/behaviors/behaviors.dart b/lib/game/components/google_word/behaviors/behaviors.dart new file mode 100644 index 00000000..4ebf817c --- /dev/null +++ b/lib/game/components/google_word/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'google_word_bonus_behavior.dart'; 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 new file mode 100644 index 00000000..92664531 --- /dev/null +++ b/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart @@ -0,0 +1,34 @@ +import 'package:flame/components.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Adds a [GameBonus.googleWord] when all [GoogleLetter]s are activated. +class GoogleWordBonusBehavior extends Component + with HasGameRef, ParentIsA { + @override + void onMount() { + super.onMount(); + + final googleLetters = parent.children.whereType(); + for (final letter in googleLetters) { + // TODO(alestiago): Refactor subscription management once the following is + // merged: + // https://github.com/flame-engine/flame/pull/1538 + letter.bloc.stream.listen((_) { + final achievedBonus = googleLetters + .every((letter) => letter.bloc.state == GoogleLetterState.active); + + if (achievedBonus) { + gameRef.audio.googleBonus(); + gameRef + .read() + .add(const BonusActivated(GameBonus.googleWord)); + for (final letter in googleLetters) { + letter.bloc.onReset(); + } + } + }); + } + } +} diff --git a/lib/game/components/google_word/google_word.dart b/lib/game/components/google_word/google_word.dart new file mode 100644 index 00000000..9a9faa9a --- /dev/null +++ b/lib/game/components/google_word/google_word.dart @@ -0,0 +1,30 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/components/google_word/behaviors/behaviors.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template google_word} +/// Loads all [GoogleLetter]s to compose a [GoogleWord]. +/// {@endtemplate} +class GoogleWord extends Component { + /// {@macro google_word} + GoogleWord({ + required Vector2 position, + }) : super( + children: [ + GoogleLetter(0)..initialPosition = position + Vector2(-12.92, 1.82), + GoogleLetter(1)..initialPosition = position + Vector2(-8.33, -0.65), + GoogleLetter(2)..initialPosition = position + Vector2(-2.88, -1.75), + GoogleLetter(3)..initialPosition = position + Vector2(2.88, -1.75), + GoogleLetter(4)..initialPosition = position + Vector2(8.33, -0.65), + GoogleLetter(5)..initialPosition = position + Vector2(12.92, 1.82), + GoogleWordBonusBehavior(), + ], + ); + + /// Creates a [GoogleWord] without any children. + /// + /// This can be used for testing [GoogleWord]'s behaviors in isolation. + @visibleForTesting + GoogleWord.test(); +} diff --git a/lib/game/components/score_points.dart b/lib/game/components/score_points.dart deleted file mode 100644 index 8a76680d..00000000 --- a/lib/game/components/score_points.dart +++ /dev/null @@ -1,47 +0,0 @@ -// ignore_for_file: avoid_renaming_method_parameters - -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; - -/// {@template score_points} -/// Specifies the amount of points received on [Ball] collision. -/// {@endtemplate} -mixin ScorePoints on BodyComponent { - /// {@macro score_points} - int get points; - - @override - Future onLoad() async { - await super.onLoad(); - body.userData = this; - } -} - -/// {@template ball_score_points_callbacks} -/// Adds points to the score when a [Ball] collides with a [BodyComponent] that -/// implements [ScorePoints]. -/// {@endtemplate} -class BallScorePointsCallback extends ContactCallback { - /// {@macro ball_score_points_callbacks} - BallScorePointsCallback(PinballGame game) : _gameRef = game; - - final PinballGame _gameRef; - - @override - void begin( - Ball ball, - ScorePoints scorePoints, - Contact _, - ) { - _gameRef.read().add(Scored(points: scorePoints.points)); - _gameRef.audio.score(); - - _gameRef.add( - ScoreText( - text: scorePoints.points.toString(), - position: ball.body.position, - ), - ); - } -} diff --git a/lib/game/components/scoring_behavior.dart b/lib/game/components/scoring_behavior.dart new file mode 100644 index 00000000..3ef82bb5 --- /dev/null +++ b/lib/game/components/scoring_behavior.dart @@ -0,0 +1,34 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template scoring_behavior} +/// Adds points to the score when the ball contacts the [parent]. +/// {@endtemplate} +class ScoringBehavior extends ContactBehavior with HasGameRef { + /// {@macro scoring_behavior} + ScoringBehavior({ + required int points, + }) : _points = points; + + final int _points; + + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + + gameRef.read().add(Scored(points: _points)); + gameRef.audio.score(); + gameRef.add( + ScoreText( + text: _points.toString(), + position: other.body.position, + ), + ); + } +} diff --git a/lib/game/components/sparky_fire_zone.dart b/lib/game/components/sparky_fire_zone.dart index a5450761..a23a4fbc 100644 --- a/lib/game/components/sparky_fire_zone.dart +++ b/lib/game/components/sparky_fire_zone.dart @@ -1,7 +1,6 @@ // ignore_for_file: avoid_renaming_method_parameters import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/material.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; @@ -17,9 +16,21 @@ class SparkyFireZone extends Blueprint { SparkyFireZone() : super( components: [ - _SparkyBumper.a()..initialPosition = Vector2(-22.9, -41.65), - _SparkyBumper.b()..initialPosition = Vector2(-21.25, -57.9), - _SparkyBumper.c()..initialPosition = Vector2(-3.3, -52.55), + SparkyBumper.a( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(-22.9, -41.65), + SparkyBumper.b( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(-21.25, -57.9), + SparkyBumper.c( + children: [ + ScoringBehavior(points: 20), + ], + )..initialPosition = Vector2(-3.3, -52.55), SparkyComputerSensor()..initialPosition = Vector2(-13, -49.8), SparkyAnimatronic()..position = Vector2(-13.8, -58.2), ], @@ -29,52 +40,14 @@ class SparkyFireZone extends Blueprint { ); } -// TODO(alestiago): Revisit ScorePoints logic once the FlameForge2D -// ContactCallback process is enhanced. -class _SparkyBumper extends SparkyBumper with ScorePoints { - _SparkyBumper.a() : super.a(); - - _SparkyBumper.b() : super.b(); - - _SparkyBumper.c() : super.c(); - - @override - int get points => 20; - - @override - Future onLoad() async { - await super.onLoad(); - // TODO(alestiago): Revisit once this has been merged: - // https://github.com/flame-engine/flame/pull/1547 - gameRef.addContactCallback(SparkyBumperBallContactCallback()); - } -} - -/// Listens when a [Ball] bounces bounces against a [SparkyBumper]. -@visibleForTesting -class SparkyBumperBallContactCallback - extends ContactCallback { - @override - void begin( - SparkyBumper sparkyBumper, - Ball _, - Contact __, - ) { - sparkyBumper.animate(); - } -} - /// {@template sparky_computer_sensor} /// Small sensor body used to detect when a ball has entered the /// [SparkyComputer]. /// {@endtemplate} -// TODO(alestiago): Revisit once this has been merged: -// https://github.com/flame-engine/flame/pull/1547 -class SparkyComputerSensor extends BodyComponent with InitialPosition { +class SparkyComputerSensor extends BodyComponent + with InitialPosition, ContactCallbacks { /// {@macro sparky_computer_sensor} - SparkyComputerSensor() { - renderBody = false; - } + SparkyComputerSensor() : super(renderBody: false); @override Body createBody() { @@ -88,23 +61,11 @@ class SparkyComputerSensor extends BodyComponent with InitialPosition { } @override - Future onLoad() async { - await super.onLoad(); - // TODO(alestiago): Revisit once this has been merged: - // https://github.com/flame-engine/flame/pull/1547 - gameRef.addContactCallback(SparkyComputerSensorBallContactCallback()); - } -} + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! ControlledBall) return; -@visibleForTesting -// TODO(alestiago): Revisit once this has been merged: -// https://github.com/flame-engine/flame/pull/1547 -// ignore: public_member_api_docs -class SparkyComputerSensorBallContactCallback - extends ContactCallback { - @override - void begin(_, ControlledBall controlledBall, __) { - controlledBall.controller.turboCharge(); - controlledBall.gameRef.firstChild()?.playing = true; + other.controller.turboCharge(); + gameRef.firstChild()?.playing = true; } } diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart index aaae1d23..2f180d61 100644 --- a/lib/game/components/wall.dart +++ b/lib/game/components/wall.dart @@ -42,25 +42,19 @@ class Wall extends BodyComponent { /// {@template bottom_wall} /// [Wall] located at the bottom of the board. /// -/// Collisions with [BottomWall] are listened by -/// [BottomWallBallContactCallback]. /// {@endtemplate} -class BottomWall extends Wall { +class BottomWall extends Wall with ContactCallbacks { /// {@macro bottom_wall} BottomWall() : super( start: BoardDimensions.bounds.bottomLeft.toVector2(), end: BoardDimensions.bounds.bottomRight.toVector2(), ); -} -/// {@template bottom_wall_ball_contact_callback} -/// Listens when a [ControlledBall] falls into a [BottomWall]. -/// {@endtemplate} -class BottomWallBallContactCallback - extends ContactCallback { @override - void begin(ControlledBall ball, BottomWall wall, Contact contact) { - ball.controller.lost(); + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! ControlledBall) return; + other.controller.lost(); } } diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index b0a36847..ab4e9860 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -78,11 +78,11 @@ extension PinballGameAssetsX on PinballGame { components.Assets.images.spaceship.ramp.arrow.active5.keyName, ), images.load(components.Assets.images.spaceship.rail.main.keyName), - images.load(components.Assets.images.spaceship.rail.foreground.keyName), - images.load(components.Assets.images.alienBumper.a.active.keyName), - images.load(components.Assets.images.alienBumper.a.inactive.keyName), - images.load(components.Assets.images.alienBumper.b.active.keyName), - images.load(components.Assets.images.alienBumper.b.inactive.keyName), + images.load(components.Assets.images.spaceship.rail.exit.keyName), + images.load(components.Assets.images.androidBumper.a.lit.keyName), + images.load(components.Assets.images.androidBumper.a.dimmed.keyName), + images.load(components.Assets.images.androidBumper.b.lit.keyName), + images.load(components.Assets.images.androidBumper.b.dimmed.keyName), images.load(components.Assets.images.sparky.computer.top.keyName), images.load(components.Assets.images.sparky.computer.base.keyName), images.load(components.Assets.images.sparky.animatronic.keyName), diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 521ce623..4b57f1dd 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -20,7 +20,7 @@ class PinballGame extends Forge2DGame HasKeyboardHandlerComponents, Controls<_GameBallsController> { PinballGame({ - required this.theme, + required this.characterTheme, required this.audio, }) { images.prefix = ''; @@ -33,7 +33,7 @@ class PinballGame extends Forge2DGame @override Color backgroundColor() => Colors.transparent; - final PinballTheme theme; + final CharacterTheme characterTheme; final PinballAudio audio; @@ -41,8 +41,6 @@ class PinballGame extends Forge2DGame @override Future onLoad() async { - _addContactCallbacks(); - unawaited(add(gameFlowController = GameFlowController(this))); unawaited(add(CameraController(this))); unawaited(add(Backboard.waiting(position: Vector2(0, -88)))); @@ -55,32 +53,11 @@ class PinballGame extends Forge2DGame final launcher = Launcher(); unawaited(addFromBlueprint(launcher)); unawaited(add(Board())); - unawaited(add(AlienZone())); await addFromBlueprint(SparkyFireZone()); + await addFromBlueprint(AndroidAcres()); unawaited(addFromBlueprint(Slingshots())); unawaited(addFromBlueprint(DinoWalls())); await add(ChromeDino()..initialPosition = Vector2(12.3, -6.9)); - unawaited(_addBonusWord()); - unawaited(addFromBlueprint(SpaceshipRamp())); - unawaited( - addFromBlueprint( - Spaceship( - position: Vector2(-26.5, -28.5), - ), - ), - ); - unawaited(addFromBlueprint(SpaceshipRail())); - - controller.attachTo(launcher.components.whereType().first); - await super.onLoad(); - } - - void _addContactCallbacks() { - addContactCallback(BallScorePointsCallback(this)); - addContactCallback(BottomWallBallContactCallback()); - } - - Future _addBonusWord() async { await add( GoogleWord( position: Vector2( @@ -89,11 +66,14 @@ class PinballGame extends Forge2DGame ), ), ); + + controller.attachTo(launcher.components.whereType().first); + await super.onLoad(); } } class _GameBallsController extends ComponentController - with BlocComponent, HasGameRef { + with BlocComponent { _GameBallsController(PinballGame game) : super(game); late final Plunger _plunger; @@ -101,9 +81,9 @@ class _GameBallsController extends ComponentController @override bool listenWhen(GameState? previousState, GameState newState) { final noBallsLeft = component.descendants().whereType().isEmpty; - final canBallRespawn = newState.balls > 0; + final notGameOver = !newState.isGameOver; - return noBallsLeft && canBallRespawn; + return noBallsLeft && notGameOver; } @override @@ -120,7 +100,7 @@ class _GameBallsController extends ComponentController void _spawnBall() { final ball = ControlledBall.launch( - theme: gameRef.theme, + characterTheme: component.characterTheme, )..initialPosition = Vector2( _plunger.body.position.x, _plunger.body.position.y - Ball.size.y, @@ -138,10 +118,10 @@ class _GameBallsController extends ComponentController class DebugPinballGame extends PinballGame with FPSCounter, TapDetector { DebugPinballGame({ - required PinballTheme theme, + required CharacterTheme characterTheme, required PinballAudio audio, }) : super( - theme: theme, + characterTheme: characterTheme, audio: audio, ) { controller = _DebugGameBallsController(this); @@ -181,20 +161,10 @@ class DebugPinballGame extends PinballGame with FPSCounter, TapDetector { class _DebugGameBallsController extends _GameBallsController { _DebugGameBallsController(PinballGame game) : super(game); - - @override - bool listenWhen(GameState? previousState, GameState newState) { - final noBallsLeft = component - .descendants() - .whereType() - .where((ball) => ball.controller is! DebugBallController) - .isEmpty; - final canBallRespawn = newState.balls > 0; - - return noBallsLeft && canBallRespawn; - } } +// TODO(wolfenrain): investigate this CI failure. +// coverage:ignore-start class _DebugInformation extends Component with HasGameRef { _DebugInformation() : super(priority: RenderPriority.debugInfo); @@ -226,3 +196,4 @@ class _DebugInformation extends Component with HasGameRef { _debugTextPaint.render(canvas, debugText, position); } } +// coverage:ignore-end diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 38ae0144..be11a15c 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -5,8 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/start_game/start_game.dart'; -import 'package:pinball/theme/theme.dart'; import 'package:pinball_audio/pinball_audio.dart'; class PinballGamePage extends StatelessWidget { @@ -31,17 +31,19 @@ class PinballGamePage extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = context.read().state.theme; + final characterTheme = + context.read().state.characterTheme; final audio = context.read(); final pinballAudio = context.read(); final game = isDebugMode - ? DebugPinballGame(theme: theme, audio: audio) - : PinballGame(theme: theme, audio: audio); + ? DebugPinballGame(characterTheme: characterTheme, audio: audio) + : PinballGame(characterTheme: characterTheme, audio: audio); final loadables = [ ...game.preLoadAssets(), pinballAudio.load(), + ...BonusAnimation.loadAssets(), ]; return MultiBlocProvider( @@ -112,6 +114,10 @@ class PinballGameLoadedView extends StatelessWidget { @override Widget build(BuildContext context) { + final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; + final screenWidth = MediaQuery.of(context).size.width; + final leftMargin = (screenWidth / 2) - (gameWidgetWidth / 1.8); + return Stack( children: [ Positioned.fill( @@ -130,10 +136,12 @@ class PinballGameLoadedView extends StatelessWidget { }, ), ), - const Positioned( - top: 8, - left: 8, - child: GameHud(), + // TODO(arturplaczek): add Visibility to GameHud based on StartGameBloc + // status + Positioned( + top: 16, + left: leftMargin, + child: const GameHud(), ), ], ); diff --git a/lib/game/view/widgets/bonus_animation.dart b/lib/game/view/widgets/bonus_animation.dart index 39cee913..da67e1aa 100644 --- a/lib/game/view/widgets/bonus_animation.dart +++ b/lib/game/view/widgets/bonus_animation.dart @@ -1,19 +1,23 @@ -// ignore_for_file: public_member_api_docs - import 'package:flame/flame.dart'; import 'package:flame/sprite.dart'; -import 'package:flame/widgets.dart'; import 'package:flutter/material.dart' hide Image; import 'package:pinball/gen/assets.gen.dart'; +import 'package:pinball_flame/pinball_flame.dart'; -class BonusAnimation extends StatelessWidget { +/// {@template bonus_animation} +/// [Widget] that displays bonus animations. +/// {@endtemplate} +class BonusAnimation extends StatefulWidget { + /// {@macro bonus_animation} const BonusAnimation._( - this.imagePath, { + String imagePath, { VoidCallback? onCompleted, Key? key, - }) : _onCompleted = onCompleted, + }) : _imagePath = imagePath, + _onCompleted = onCompleted, super(key: key); + /// [Widget] that displays the dash nest animation. BonusAnimation.dashNest({ Key? key, VoidCallback? onCompleted, @@ -23,6 +27,7 @@ class BonusAnimation extends StatelessWidget { key: key, ); + /// [Widget] that displays the sparky turbo charge animation. BonusAnimation.sparkyTurboCharge({ Key? key, VoidCallback? onCompleted, @@ -32,56 +37,94 @@ class BonusAnimation extends StatelessWidget { key: key, ); - BonusAnimation.dino({ + /// [Widget] that displays the dino chomp animation. + BonusAnimation.dinoChomp({ Key? key, VoidCallback? onCompleted, }) : this._( - Assets.images.bonusAnimation.dino.keyName, + Assets.images.bonusAnimation.dinoChomp.keyName, onCompleted: onCompleted, key: key, ); - BonusAnimation.android({ + /// [Widget] that displays the android spaceship animation. + BonusAnimation.androidSpaceship({ Key? key, VoidCallback? onCompleted, }) : this._( - Assets.images.bonusAnimation.android.keyName, + Assets.images.bonusAnimation.androidSpaceship.keyName, onCompleted: onCompleted, key: key, ); - BonusAnimation.google({ + /// [Widget] that displays the google word animation. + BonusAnimation.googleWord({ Key? key, VoidCallback? onCompleted, }) : this._( - Assets.images.bonusAnimation.google.keyName, + Assets.images.bonusAnimation.googleWord.keyName, onCompleted: onCompleted, key: key, ); - final String imagePath; + final String _imagePath; final VoidCallback? _onCompleted; - static Future loadAssets() { + /// Returns a list of assets to be loaded for animations. + static List loadAssets() { Flame.images.prefix = ''; - return Flame.images.loadAll([ - Assets.images.bonusAnimation.dashNest.keyName, - Assets.images.bonusAnimation.sparkyTurboCharge.keyName, - Assets.images.bonusAnimation.dino.keyName, - Assets.images.bonusAnimation.android.keyName, - Assets.images.bonusAnimation.google.keyName, - ]); + return [ + Flame.images.load(Assets.images.bonusAnimation.dashNest.keyName), + Flame.images.load(Assets.images.bonusAnimation.sparkyTurboCharge.keyName), + Flame.images.load(Assets.images.bonusAnimation.dinoChomp.keyName), + Flame.images.load(Assets.images.bonusAnimation.androidSpaceship.keyName), + Flame.images.load(Assets.images.bonusAnimation.googleWord.keyName), + ]; + } + + @override + State createState() => _BonusAnimationState(); +} + +class _BonusAnimationState extends State + with TickerProviderStateMixin { + late SpriteAnimationController controller; + late SpriteAnimation animation; + bool shouldRunBuildCallback = true; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + // When the animation is overwritten by another animation, we need to stop + // the callback in the build method as it will break the new animation. + // Otherwise we need to set up a new callback when a new animation starts to + // show the score view at the end of the animation. + @override + void didUpdateWidget(BonusAnimation oldWidget) { + shouldRunBuildCallback = oldWidget._imagePath == widget._imagePath; + + Future.delayed( + Duration(seconds: animation.totalDuration().ceil()), + () { + widget._onCompleted?.call(); + }, + ); + + super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { final spriteSheet = SpriteSheet.fromColumnsAndRows( - image: Flame.images.fromCache(imagePath), + image: Flame.images.fromCache(widget._imagePath), columns: 8, rows: 9, ); - final animation = spriteSheet.createAnimation( + animation = spriteSheet.createAnimation( row: 0, stepTime: 1 / 24, to: spriteSheet.rows * spriteSheet.columns, @@ -91,15 +134,22 @@ class BonusAnimation extends StatelessWidget { Future.delayed( Duration(seconds: animation.totalDuration().ceil()), () { - _onCompleted?.call(); + if (shouldRunBuildCallback) { + widget._onCompleted?.call(); + } }, ); + controller = SpriteAnimationController( + animation: animation, + vsync: this, + )..forward(); + return SizedBox( width: double.infinity, height: double.infinity, child: SpriteAnimationWidget( - animation: animation, + controller: controller, ), ); } diff --git a/lib/game/view/widgets/game_hud.dart b/lib/game/view/widgets/game_hud.dart index 00eedd2b..9cfb2d67 100644 --- a/lib/game/view/widgets/game_hud.dart +++ b/lib/game/view/widgets/game_hud.dart @@ -1,46 +1,122 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/gen/gen.dart'; +import 'package:pinball/theme/app_colors.dart'; /// {@template game_hud} -/// Overlay of a [PinballGame] that displays the current [GameState.score] and -/// [GameState.balls]. +/// Overlay on the [PinballGame]. +/// +/// Displays the current [GameState.score], [GameState.rounds] and animates when +/// the player gets a [GameBonus]. /// {@endtemplate} -class GameHud extends StatelessWidget { +class GameHud extends StatefulWidget { /// {@macro game_hud} const GameHud({Key? key}) : super(key: key); + @override + State createState() => _GameHudState(); +} + +class _GameHudState extends State { + bool showAnimation = false; + + /// Ratio from sprite frame (width 500, height 144) w / h = ratio + static const _ratio = 3.47; + static const _width = 265.0; + @override Widget build(BuildContext context) { - final state = context.watch().state; - - return Container( - color: Colors.redAccent, - width: 200, - height: 100, - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${state.score}', - style: Theme.of(context).textTheme.headline3, + final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver); + + return _ScoreViewDecoration( + child: SizedBox( + height: _width / _ratio, + width: _width, + child: BlocListener( + listenWhen: (previous, current) => + previous.bonusHistory.length != current.bonusHistory.length, + listener: (_, __) => setState(() => showAnimation = true), + child: AnimatedSwitcher( + duration: kThemeAnimationDuration, + child: showAnimation && !isGameOver + ? _AnimationView( + onComplete: () { + if (mounted) { + setState(() => showAnimation = false); + } + }, + ) + : const ScoreView(), ), - Wrap( - direction: Axis.vertical, - children: [ - for (var i = 0; i < state.balls; i++) - const Padding( - padding: EdgeInsets.only(top: 6, right: 6), - child: CircleAvatar( - radius: 8, - backgroundColor: Colors.black, - ), - ), - ], + ), + ), + ); + } +} + +class _ScoreViewDecoration extends StatelessWidget { + const _ScoreViewDecoration({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) { + const radius = BorderRadius.all(Radius.circular(12)); + const boardWidth = 5.0; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all( + color: AppColors.white, + width: boardWidth, + ), + image: DecorationImage( + fit: BoxFit.cover, + image: AssetImage( + Assets.images.score.miniScoreBackground.path, ), - ], + ), ), + child: Padding( + padding: const EdgeInsets.all(boardWidth - 1), + child: ClipRRect( + borderRadius: radius, + child: child, + ), + ), + ); + } +} + +class _AnimationView extends StatelessWidget { + const _AnimationView({ + Key? key, + required this.onComplete, + }) : super(key: key); + + final VoidCallback onComplete; + + @override + Widget build(BuildContext context) { + final lastBonus = context.select( + (GameBloc bloc) => bloc.state.bonusHistory.last, ); + switch (lastBonus) { + case GameBonus.dashNest: + return BonusAnimation.dashNest(onCompleted: onComplete); + case GameBonus.sparkyTurboCharge: + return BonusAnimation.sparkyTurboCharge(onCompleted: onComplete); + case GameBonus.dinoChomp: + return BonusAnimation.dinoChomp(onCompleted: onComplete); + case GameBonus.googleWord: + return BonusAnimation.googleWord(onCompleted: onComplete); + case GameBonus.androidSpaceship: + return BonusAnimation.androidSpaceship(onCompleted: onComplete); + } } } diff --git a/lib/game/view/widgets/play_button_overlay.dart b/lib/game/view/widgets/play_button_overlay.dart index ce5dce4b..f90ebb98 100644 --- a/lib/game/view/widgets/play_button_overlay.dart +++ b/lib/game/view/widgets/play_button_overlay.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:pinball/game/pinball_game.dart'; import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; /// {@template play_button_overlay} /// [Widget] that renders the button responsible to starting the game diff --git a/lib/game/view/widgets/round_count_display.dart b/lib/game/view/widgets/round_count_display.dart new file mode 100644 index 00000000..30135cd2 --- /dev/null +++ b/lib/game/view/widgets/round_count_display.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/theme/theme.dart'; + +/// {@template round_count_display} +/// Colored square indicating if a round is available. +/// {@endtemplate} +class RoundCountDisplay extends StatelessWidget { + /// {@macro round_count_display} + const RoundCountDisplay({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final rounds = context.select((GameBloc bloc) => bloc.state.rounds); + + return Row( + children: [ + Text( + l10n.rounds, + style: AppTextStyle.subtitle1.copyWith( + color: AppColors.orange, + ), + ), + const SizedBox(width: 8), + Row( + children: [ + RoundIndicator(isActive: rounds >= 1), + RoundIndicator(isActive: rounds >= 2), + RoundIndicator(isActive: rounds >= 3), + ], + ), + ], + ); + } +} + +/// {@template round_indicator} +/// [Widget] that displays the round indicator. +/// {@endtemplate} +@visibleForTesting +class RoundIndicator extends StatelessWidget { + /// {@macro round_indicator} + const RoundIndicator({ + Key? key, + required this.isActive, + }) : super(key: key); + + /// A value that describes whether the indicator is active. + final bool isActive; + + @override + Widget build(BuildContext context) { + final color = isActive ? AppColors.orange : AppColors.orange.withAlpha(128); + const size = 8.0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Container( + color: color, + height: size, + width: size, + ), + ); + } +} diff --git a/lib/game/view/widgets/score_view.dart b/lib/game/view/widgets/score_view.dart new file mode 100644 index 00000000..288ea05c --- /dev/null +++ b/lib/game/view/widgets/score_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/theme/theme.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template score_view} +/// [Widget] that displays the score. +/// {@endtemplate} +class ScoreView extends StatelessWidget { + /// {@macro score_view} + const ScoreView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: AnimatedSwitcher( + duration: kThemeAnimationDuration, + child: isGameOver ? const _GameOver() : const _ScoreDisplay(), + ), + ); + } +} + +class _GameOver extends StatelessWidget { + const _GameOver({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Text( + l10n.gameOver, + style: AppTextStyle.headline1.copyWith( + color: AppColors.white, + ), + ); + } +} + +class _ScoreDisplay extends StatelessWidget { + const _ScoreDisplay({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + l10n.score.toLowerCase(), + style: AppTextStyle.subtitle1.copyWith( + color: AppColors.orange, + ), + ), + const _ScoreText(), + const RoundCountDisplay(), + ], + ); + } +} + +class _ScoreText extends StatelessWidget { + const _ScoreText({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final score = context.select((GameBloc bloc) => bloc.state.score); + + return Text( + score.formatScore(), + style: AppTextStyle.headline1.copyWith( + color: AppColors.white, + ), + ); + } +} diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart index 674577af..5d1fccf8 100644 --- a/lib/game/view/widgets/widgets.dart +++ b/lib/game/view/widgets/widgets.dart @@ -1,3 +1,5 @@ export 'bonus_animation.dart'; export 'game_hud.dart'; export 'play_button_overlay.dart'; +export 'round_count_display.dart'; +export 'score_view.dart'; diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 3e52e399..f5b935a5 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -14,26 +14,27 @@ class $AssetsImagesGen { const $AssetsImagesBonusAnimationGen(); $AssetsImagesComponentsGen get components => const $AssetsImagesComponentsGen(); + $AssetsImagesScoreGen get score => const $AssetsImagesScoreGen(); } class $AssetsImagesBonusAnimationGen { const $AssetsImagesBonusAnimationGen(); - /// File path: assets/images/bonus_animation/android.png - AssetGenImage get android => - const AssetGenImage('assets/images/bonus_animation/android.png'); + /// File path: assets/images/bonus_animation/android_spaceship.png + AssetGenImage get androidSpaceship => const AssetGenImage( + 'assets/images/bonus_animation/android_spaceship.png'); /// File path: assets/images/bonus_animation/dash_nest.png AssetGenImage get dashNest => const AssetGenImage('assets/images/bonus_animation/dash_nest.png'); - /// File path: assets/images/bonus_animation/dino.png - AssetGenImage get dino => - const AssetGenImage('assets/images/bonus_animation/dino.png'); + /// File path: assets/images/bonus_animation/dino_chomp.png + AssetGenImage get dinoChomp => + const AssetGenImage('assets/images/bonus_animation/dino_chomp.png'); - /// File path: assets/images/bonus_animation/google.png - AssetGenImage get google => - const AssetGenImage('assets/images/bonus_animation/google.png'); + /// File path: assets/images/bonus_animation/google_word.png + AssetGenImage get googleWord => + const AssetGenImage('assets/images/bonus_animation/google_word.png'); /// File path: assets/images/bonus_animation/sparky_turbo_charge.png AssetGenImage get sparkyTurboCharge => const AssetGenImage( @@ -48,6 +49,14 @@ class $AssetsImagesComponentsGen { const AssetGenImage('assets/images/components/background.png'); } +class $AssetsImagesScoreGen { + const $AssetsImagesScoreGen(); + + /// File path: assets/images/score/mini_score_background.png + AssetGenImage get miniScoreBackground => + const AssetGenImage('assets/images/score/mini_score_background.png'); +} + class Assets { Assets._(); diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index c551535f..9655d8be 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -75,5 +75,9 @@ "enterInitials": "Enter your initials", "@enterInitials": { "description": "Text displayed on the ending dialog when game finishes to ask the user for his initials" + }, + "rounds": "Ball Ct:", + "@rounds": { + "description": "Text displayed on the scoreboard widget to indicate rounds left" } } diff --git a/lib/leaderboard/view/leaderboard_page.dart b/lib/leaderboard/view/leaderboard_page.dart index 61e63d75..b9866111 100644 --- a/lib/leaderboard/view/leaderboard_page.dart +++ b/lib/leaderboard/view/leaderboard_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/leaderboard/leaderboard.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_theme/pinball_theme.dart'; class LeaderboardPage extends StatelessWidget { diff --git a/lib/theme/cubit/theme_cubit.dart b/lib/select_character/cubit/character_theme_cubit.dart similarity index 59% rename from lib/theme/cubit/theme_cubit.dart rename to lib/select_character/cubit/character_theme_cubit.dart index 94eba4a6..84792a71 100644 --- a/lib/theme/cubit/theme_cubit.dart +++ b/lib/select_character/cubit/character_theme_cubit.dart @@ -5,12 +5,12 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:pinball_theme/pinball_theme.dart'; -part 'theme_state.dart'; +part 'character_theme_state.dart'; -class ThemeCubit extends Cubit { - ThemeCubit() : super(const ThemeState.initial()); +class CharacterThemeCubit extends Cubit { + CharacterThemeCubit() : super(const CharacterThemeState.initial()); void characterSelected(CharacterTheme characterTheme) { - emit(ThemeState(PinballTheme(characterTheme: characterTheme))); + emit(CharacterThemeState(characterTheme)); } } diff --git a/lib/select_character/cubit/character_theme_state.dart b/lib/select_character/cubit/character_theme_state.dart new file mode 100644 index 00000000..ffe5667c --- /dev/null +++ b/lib/select_character/cubit/character_theme_state.dart @@ -0,0 +1,15 @@ +// ignore_for_file: public_member_api_docs +// TODO(allisonryan0002): Document this section when the API is stable. + +part of 'character_theme_cubit.dart'; + +class CharacterThemeState extends Equatable { + const CharacterThemeState(this.characterTheme); + + const CharacterThemeState.initial() : characterTheme = const DashTheme(); + + final CharacterTheme characterTheme; + + @override + List get props => [characterTheme]; +} diff --git a/lib/select_character/select_character.dart b/lib/select_character/select_character.dart new file mode 100644 index 00000000..40699840 --- /dev/null +++ b/lib/select_character/select_character.dart @@ -0,0 +1,2 @@ +export 'cubit/character_theme_cubit.dart'; +export 'view/view.dart'; diff --git a/lib/theme/view/character_selection_page.dart b/lib/select_character/view/character_selection_page.dart similarity index 91% rename from lib/theme/view/character_selection_page.dart rename to lib/select_character/view/character_selection_page.dart index ef37270b..0e83db8d 100644 --- a/lib/theme/view/character_selection_page.dart +++ b/lib/select_character/view/character_selection_page.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/start_game/start_game.dart'; -import 'package:pinball/theme/theme.dart'; import 'package:pinball_theme/pinball_theme.dart'; class CharacterSelectionDialog extends StatelessWidget { @@ -19,7 +19,7 @@ class CharacterSelectionDialog extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => ThemeCubit(), + create: (_) => CharacterThemeCubit(), child: const CharacterSelectionView(), ); } @@ -109,12 +109,14 @@ class CharacterImageButton extends StatelessWidget { @override Widget build(BuildContext context) { - final currentCharacterTheme = context.select( - (cubit) => cubit.state.theme.characterTheme, + final currentCharacterTheme = + context.select( + (cubit) => cubit.state.characterTheme, ); return GestureDetector( - onTap: () => context.read().characterSelected(characterTheme), + onTap: () => + context.read().characterSelected(characterTheme), child: DecoratedBox( decoration: BoxDecoration( color: (currentCharacterTheme == characterTheme) diff --git a/lib/theme/view/view.dart b/lib/select_character/view/view.dart similarity index 100% rename from lib/theme/view/view.dart rename to lib/select_character/view/view.dart diff --git a/lib/theme/app_text_style.dart b/lib/theme/app_text_style.dart index 068f1eb9..8104ca11 100644 --- a/lib/theme/app_text_style.dart +++ b/lib/theme/app_text_style.dart @@ -5,7 +5,7 @@ import 'package:pinball/theme/theme.dart'; import 'package:pinball_components/pinball_components.dart'; const _fontPackage = 'pinball_components'; -const _primaryFontFamily = PinballFonts.pixeloidSans; +const _primaryFontFamily = FontFamily.pixeloidSans; abstract class AppTextStyle { static const headline1 = TextStyle( diff --git a/lib/theme/cubit/theme_state.dart b/lib/theme/cubit/theme_state.dart deleted file mode 100644 index 078f5c84..00000000 --- a/lib/theme/cubit/theme_state.dart +++ /dev/null @@ -1,16 +0,0 @@ -// ignore_for_file: public_member_api_docs -// TODO(allisonryan0002): Document this section when the API is stable. - -part of 'theme_cubit.dart'; - -class ThemeState extends Equatable { - const ThemeState(this.theme); - - const ThemeState.initial() - : theme = const PinballTheme(characterTheme: DashTheme()); - - final PinballTheme theme; - - @override - List get props => [theme]; -} diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 5e4fefe9..c9e2f9e1 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -1,4 +1,2 @@ export 'app_colors.dart'; export 'app_text_style.dart'; -export 'cubit/theme_cubit.dart'; -export 'view/view.dart'; diff --git a/packages/pinball_components/assets/images/alien_bumper/a/inactive.png b/packages/pinball_components/assets/images/android_bumper/a/dimmed.png similarity index 100% rename from packages/pinball_components/assets/images/alien_bumper/a/inactive.png rename to packages/pinball_components/assets/images/android_bumper/a/dimmed.png diff --git a/packages/pinball_components/assets/images/alien_bumper/a/active.png b/packages/pinball_components/assets/images/android_bumper/a/lit.png similarity index 100% rename from packages/pinball_components/assets/images/alien_bumper/a/active.png rename to packages/pinball_components/assets/images/android_bumper/a/lit.png diff --git a/packages/pinball_components/assets/images/alien_bumper/b/inactive.png b/packages/pinball_components/assets/images/android_bumper/b/dimmed.png similarity index 100% rename from packages/pinball_components/assets/images/alien_bumper/b/inactive.png rename to packages/pinball_components/assets/images/android_bumper/b/dimmed.png diff --git a/packages/pinball_components/assets/images/alien_bumper/b/active.png b/packages/pinball_components/assets/images/android_bumper/b/lit.png similarity index 100% rename from packages/pinball_components/assets/images/alien_bumper/b/active.png rename to packages/pinball_components/assets/images/android_bumper/b/lit.png diff --git a/packages/pinball_components/assets/images/boundary/outer.png b/packages/pinball_components/assets/images/boundary/outer.png index 3c06cb6c..1f3bab69 100644 Binary files a/packages/pinball_components/assets/images/boundary/outer.png and b/packages/pinball_components/assets/images/boundary/outer.png differ diff --git a/packages/pinball_components/assets/images/spaceship/rail/exit.png b/packages/pinball_components/assets/images/spaceship/rail/exit.png new file mode 100644 index 00000000..80a819d0 Binary files /dev/null and b/packages/pinball_components/assets/images/spaceship/rail/exit.png differ diff --git a/packages/pinball_components/assets/images/spaceship/rail/foreground.png b/packages/pinball_components/assets/images/spaceship/rail/foreground.png deleted file mode 100644 index 4d11e865..00000000 Binary files a/packages/pinball_components/assets/images/spaceship/rail/foreground.png and /dev/null differ diff --git a/packages/pinball_components/assets/images/spaceship/rail/main.png b/packages/pinball_components/assets/images/spaceship/rail/main.png index 4b299c2c..9291c784 100644 Binary files a/packages/pinball_components/assets/images/spaceship/rail/main.png and b/packages/pinball_components/assets/images/spaceship/rail/main.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index 73a25af7..1a272d31 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -10,8 +10,8 @@ import 'package:flutter/widgets.dart'; class $AssetsImagesGen { const $AssetsImagesGen(); - $AssetsImagesAlienBumperGen get alienBumper => - const $AssetsImagesAlienBumperGen(); + $AssetsImagesAndroidBumperGen get androidBumper => + const $AssetsImagesAndroidBumperGen(); $AssetsImagesBackboardGen get backboard => const $AssetsImagesBackboardGen(); $AssetsImagesBallGen get ball => const $AssetsImagesBallGen(); $AssetsImagesBaseboardGen get baseboard => const $AssetsImagesBaseboardGen(); @@ -31,11 +31,13 @@ class $AssetsImagesGen { $AssetsImagesSparkyGen get sparky => const $AssetsImagesSparkyGen(); } -class $AssetsImagesAlienBumperGen { - const $AssetsImagesAlienBumperGen(); +class $AssetsImagesAndroidBumperGen { + const $AssetsImagesAndroidBumperGen(); - $AssetsImagesAlienBumperAGen get a => const $AssetsImagesAlienBumperAGen(); - $AssetsImagesAlienBumperBGen get b => const $AssetsImagesAlienBumperBGen(); + $AssetsImagesAndroidBumperAGen get a => + const $AssetsImagesAndroidBumperAGen(); + $AssetsImagesAndroidBumperBGen get b => + const $AssetsImagesAndroidBumperBGen(); } class $AssetsImagesBackboardGen { @@ -258,28 +260,28 @@ class $AssetsImagesSparkyGen { const $AssetsImagesSparkyComputerGen(); } -class $AssetsImagesAlienBumperAGen { - const $AssetsImagesAlienBumperAGen(); +class $AssetsImagesAndroidBumperAGen { + const $AssetsImagesAndroidBumperAGen(); - /// File path: assets/images/alien_bumper/a/active.png - AssetGenImage get active => - const AssetGenImage('assets/images/alien_bumper/a/active.png'); + /// File path: assets/images/android_bumper/a/dimmed.png + AssetGenImage get dimmed => + const AssetGenImage('assets/images/android_bumper/a/dimmed.png'); - /// File path: assets/images/alien_bumper/a/inactive.png - AssetGenImage get inactive => - const AssetGenImage('assets/images/alien_bumper/a/inactive.png'); + /// File path: assets/images/android_bumper/a/lit.png + AssetGenImage get lit => + const AssetGenImage('assets/images/android_bumper/a/lit.png'); } -class $AssetsImagesAlienBumperBGen { - const $AssetsImagesAlienBumperBGen(); +class $AssetsImagesAndroidBumperBGen { + const $AssetsImagesAndroidBumperBGen(); - /// File path: assets/images/alien_bumper/b/active.png - AssetGenImage get active => - const AssetGenImage('assets/images/alien_bumper/b/active.png'); + /// File path: assets/images/android_bumper/b/dimmed.png + AssetGenImage get dimmed => + const AssetGenImage('assets/images/android_bumper/b/dimmed.png'); - /// File path: assets/images/alien_bumper/b/inactive.png - AssetGenImage get inactive => - const AssetGenImage('assets/images/alien_bumper/b/inactive.png'); + /// File path: assets/images/android_bumper/b/lit.png + AssetGenImage get lit => + const AssetGenImage('assets/images/android_bumper/b/lit.png'); } class $AssetsImagesDashBumperGen { @@ -306,9 +308,9 @@ class $AssetsImagesDinoAnimatronicGen { class $AssetsImagesSpaceshipRailGen { const $AssetsImagesSpaceshipRailGen(); - /// File path: assets/images/spaceship/rail/foreground.png - AssetGenImage get foreground => - const AssetGenImage('assets/images/spaceship/rail/foreground.png'); + /// File path: assets/images/spaceship/rail/exit.png + AssetGenImage get exit => + const AssetGenImage('assets/images/spaceship/rail/exit.png'); /// File path: assets/images/spaceship/rail/main.png AssetGenImage get main => diff --git a/packages/pinball_components/lib/gen/gen.dart b/packages/pinball_components/lib/gen/gen.dart index 0171b231..ada8b777 100644 --- a/packages/pinball_components/lib/gen/gen.dart +++ b/packages/pinball_components/lib/gen/gen.dart @@ -1,2 +1,3 @@ export 'assets.gen.dart'; +export 'fonts.gen.dart'; export 'pinball_fonts.dart'; diff --git a/packages/pinball_components/lib/src/components/alien_bumper.dart b/packages/pinball_components/lib/src/components/alien_bumper.dart deleted file mode 100644 index 1f96d214..00000000 --- a/packages/pinball_components/lib/src/components/alien_bumper.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:async'; - -import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/material.dart'; -import 'package:pinball_components/pinball_components.dart'; - -/// {@template alien_bumper} -/// Bumper for area under the [Spaceship]. -/// {@endtemplate} -class AlienBumper extends BodyComponent with InitialPosition { - /// {@macro alien_bumper} - AlienBumper._({ - required double majorRadius, - required double minorRadius, - required String onAssetPath, - required String offAssetPath, - }) : _majorRadius = majorRadius, - _minorRadius = minorRadius, - super( - priority: RenderPriority.alienBumper, - children: [ - _AlienBumperSpriteGroupComponent( - onAssetPath: onAssetPath, - offAssetPath: offAssetPath, - ), - ], - ) { - renderBody = false; - } - - /// {@macro alien_bumper} - AlienBumper.a() - : this._( - majorRadius: 3.52, - minorRadius: 2.97, - onAssetPath: Assets.images.alienBumper.a.active.keyName, - offAssetPath: Assets.images.alienBumper.a.inactive.keyName, - ); - - /// {@macro alien_bumper} - AlienBumper.b() - : this._( - majorRadius: 3.19, - minorRadius: 2.79, - onAssetPath: Assets.images.alienBumper.b.active.keyName, - offAssetPath: Assets.images.alienBumper.b.inactive.keyName, - ); - - final double _majorRadius; - final double _minorRadius; - - @override - Body createBody() { - final shape = EllipseShape( - center: Vector2.zero(), - majorRadius: _majorRadius, - minorRadius: _minorRadius, - )..rotate(1.29); - final fixtureDef = FixtureDef( - shape, - restitution: 4, - ); - final bodyDef = BodyDef( - position: initialPosition, - userData: this, - ); - - return world.createBody(bodyDef)..createFixture(fixtureDef); - } - - /// Animates the [AlienBumper]. - Future animate() async { - final spriteGroupComponent = firstChild<_AlienBumperSpriteGroupComponent>() - ?..current = AlienBumperSpriteState.inactive; - await Future.delayed(const Duration(milliseconds: 50)); - spriteGroupComponent?.current = AlienBumperSpriteState.active; - } -} - -/// Indicates the [AlienBumper]'s current sprite state. -@visibleForTesting -enum AlienBumperSpriteState { - /// A lit up bumper. - active, - - /// A dimmed bumper. - inactive, -} - -class _AlienBumperSpriteGroupComponent - extends SpriteGroupComponent with HasGameRef { - _AlienBumperSpriteGroupComponent({ - required String onAssetPath, - required String offAssetPath, - }) : _onAssetPath = onAssetPath, - _offAssetPath = offAssetPath, - super( - anchor: Anchor.center, - position: Vector2(0, -0.1), - ); - - final String _onAssetPath; - final String _offAssetPath; - - @override - Future onLoad() async { - await super.onLoad(); - - final sprites = { - AlienBumperSpriteState.active: - Sprite(gameRef.images.fromCache(_onAssetPath)), - AlienBumperSpriteState.inactive: - Sprite(gameRef.images.fromCache(_offAssetPath)), - }; - this.sprites = sprites; - - current = AlienBumperSpriteState.active; - size = sprites[current]!.originalSize / 10; - } -} diff --git a/packages/pinball_components/lib/src/components/android_bumper/android_bumper.dart b/packages/pinball_components/lib/src/components/android_bumper/android_bumper.dart new file mode 100644 index 00000000..ad954975 --- /dev/null +++ b/packages/pinball_components/lib/src/components/android_bumper/android_bumper.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +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/android_bumper/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'cubit/android_bumper_cubit.dart'; + +/// {@template android_bumper} +/// Bumper for area under the [Spaceship]. +/// {@endtemplate} +class AndroidBumper extends BodyComponent with InitialPosition { + /// {@macro android_bumper} + AndroidBumper._({ + required double majorRadius, + required double minorRadius, + required String litAssetPath, + required String dimmedAssetPath, + Iterable? children, + required this.bloc, + }) : _majorRadius = majorRadius, + _minorRadius = minorRadius, + super( + priority: RenderPriority.androidBumper, + renderBody: false, + children: [ + AndroidBumperBallContactBehavior(), + AndroidBumperBlinkingBehavior(), + _AndroidBumperSpriteGroupComponent( + dimmedAssetPath: dimmedAssetPath, + litAssetPath: litAssetPath, + state: bloc.state, + ), + ...?children, + ], + ); + + /// {@macro android_bumper} + AndroidBumper.a({ + Iterable? children, + }) : this._( + majorRadius: 3.52, + minorRadius: 2.97, + litAssetPath: Assets.images.androidBumper.a.lit.keyName, + dimmedAssetPath: Assets.images.androidBumper.a.dimmed.keyName, + bloc: AndroidBumperCubit(), + children: children, + ); + + /// {@macro android_bumper} + AndroidBumper.b({ + Iterable? children, + }) : this._( + majorRadius: 3.19, + minorRadius: 2.79, + litAssetPath: Assets.images.androidBumper.b.lit.keyName, + dimmedAssetPath: Assets.images.androidBumper.b.dimmed.keyName, + bloc: AndroidBumperCubit(), + children: children, + ); + + /// Creates an [AndroidBumper] without any children. + /// + /// This can be used for testing [AndroidBumper]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + AndroidBumper.test({ + required this.bloc, + }) : _majorRadius = 3.52, + _minorRadius = 2.97; + + final double _majorRadius; + + final double _minorRadius; + + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final AndroidBumperCubit bloc; + + @override + void onRemove() { + bloc.close(); + super.onRemove(); + } + + @override + Body createBody() { + final shape = EllipseShape( + center: Vector2.zero(), + majorRadius: _majorRadius, + minorRadius: _minorRadius, + )..rotate(1.29); + final fixtureDef = FixtureDef( + shape, + restitution: 4, + ); + final bodyDef = BodyDef( + position: initialPosition, + ); + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} + +class _AndroidBumperSpriteGroupComponent + extends SpriteGroupComponent + with HasGameRef, ParentIsA { + _AndroidBumperSpriteGroupComponent({ + required String litAssetPath, + required String dimmedAssetPath, + required AndroidBumperState state, + }) : _litAssetPath = litAssetPath, + _dimmedAssetPath = dimmedAssetPath, + super( + anchor: Anchor.center, + position: Vector2(0, -0.1), + current: state, + ); + + final String _litAssetPath; + final String _dimmedAssetPath; + + @override + Future onLoad() async { + await super.onLoad(); + parent.bloc.stream.listen((state) => current = state); + + final sprites = { + AndroidBumperState.lit: Sprite( + gameRef.images.fromCache(_litAssetPath), + ), + AndroidBumperState.dimmed: + Sprite(gameRef.images.fromCache(_dimmedAssetPath)), + }; + this.sprites = sprites; + size = sprites[current]!.originalSize / 10; + } +} diff --git a/packages/pinball_components/lib/src/components/android_bumper/behaviors/android_bumper_ball_contact_behavior.dart b/packages/pinball_components/lib/src/components/android_bumper/behaviors/android_bumper_ball_contact_behavior.dart new file mode 100644 index 00000000..d28aa39c --- /dev/null +++ b/packages/pinball_components/lib/src/components/android_bumper/behaviors/android_bumper_ball_contact_behavior.dart @@ -0,0 +1,14 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class AndroidBumperBallContactBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + parent.bloc.onBallContacted(); + } +} diff --git a/packages/pinball_components/lib/src/components/android_bumper/behaviors/android_bumper_blinking_behavior.dart b/packages/pinball_components/lib/src/components/android_bumper/behaviors/android_bumper_blinking_behavior.dart new file mode 100644 index 00000000..4f7dc135 --- /dev/null +++ b/packages/pinball_components/lib/src/components/android_bumper/behaviors/android_bumper_blinking_behavior.dart @@ -0,0 +1,39 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template android_bumper_blinking_behavior} +/// Makes an [AndroidBumper] blink back to [AndroidBumperState.lit] when +/// [AndroidBumperState.dimmed]. +/// {@endtemplate} +class AndroidBumperBlinkingBehavior extends TimerComponent + with ParentIsA { + /// {@macro android_bumper_blinking_behavior} + AndroidBumperBlinkingBehavior() : super(period: 0.05); + + void _onNewState(AndroidBumperState state) { + switch (state) { + case AndroidBumperState.lit: + break; + case AndroidBumperState.dimmed: + timer + ..reset() + ..start(); + break; + } + } + + @override + Future onLoad() async { + await super.onLoad(); + timer.stop(); + parent.bloc.stream.listen(_onNewState); + } + + @override + void onTick() { + super.onTick(); + timer.stop(); + parent.bloc.onBlinked(); + } +} diff --git a/packages/pinball_components/lib/src/components/android_bumper/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/android_bumper/behaviors/behaviors.dart new file mode 100644 index 00000000..f7ce4900 --- /dev/null +++ b/packages/pinball_components/lib/src/components/android_bumper/behaviors/behaviors.dart @@ -0,0 +1,2 @@ +export 'android_bumper_ball_contact_behavior.dart'; +export 'android_bumper_blinking_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/android_bumper/cubit/android_bumper_cubit.dart b/packages/pinball_components/lib/src/components/android_bumper/cubit/android_bumper_cubit.dart new file mode 100644 index 00000000..3d3fd4b1 --- /dev/null +++ b/packages/pinball_components/lib/src/components/android_bumper/cubit/android_bumper_cubit.dart @@ -0,0 +1,17 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; + +part 'android_bumper_state.dart'; + +class AndroidBumperCubit extends Cubit { + AndroidBumperCubit() : super(AndroidBumperState.dimmed); + + void onBallContacted() { + emit(AndroidBumperState.dimmed); + } + + void onBlinked() { + emit(AndroidBumperState.lit); + } +} diff --git a/packages/pinball_components/lib/src/components/android_bumper/cubit/android_bumper_state.dart b/packages/pinball_components/lib/src/components/android_bumper/cubit/android_bumper_state.dart new file mode 100644 index 00000000..f101c3e9 --- /dev/null +++ b/packages/pinball_components/lib/src/components/android_bumper/cubit/android_bumper_state.dart @@ -0,0 +1,8 @@ +// ignore_for_file: public_member_api_docs + +part of 'android_bumper_cubit.dart'; + +enum AndroidBumperState { + lit, + dimmed, +} diff --git a/packages/pinball_components/lib/src/components/ball.dart b/packages/pinball_components/lib/src/components/ball.dart index b1e2703b..1c9c1270 100644 --- a/packages/pinball_components/lib/src/components/ball.dart +++ b/packages/pinball_components/lib/src/components/ball.dart @@ -16,6 +16,7 @@ class Ball extends BodyComponent Ball({ required this.baseColor, }) : super( + renderBody: false, children: [ _BallSpriteComponent()..tint(baseColor.withOpacity(0.5)), ], @@ -26,7 +27,6 @@ class Ball extends BodyComponent // We need to see what happens if Ball appears from other place like nest // bumper, it will need to explicit change layer to Layer.board then. layer = Layer.board; - renderBody = false; } /// The size of the [Ball]. diff --git a/packages/pinball_components/lib/src/components/baseboard.dart b/packages/pinball_components/lib/src/components/baseboard.dart index 07f39070..47ba4666 100644 --- a/packages/pinball_components/lib/src/components/baseboard.dart +++ b/packages/pinball_components/lib/src/components/baseboard.dart @@ -13,10 +13,9 @@ class Baseboard extends BodyComponent with InitialPosition { required BoardSide side, }) : _side = side, super( + renderBody: false, children: [_BaseboardSpriteComponent(side: side)], - ) { - renderBody = false; - } + ); /// Whether the [Baseboard] is on the left or right side of the board. final BoardSide _side; diff --git a/packages/pinball_components/lib/src/components/boundaries.dart b/packages/pinball_components/lib/src/components/boundaries.dart index 607b22de..3d0f9445 100644 --- a/packages/pinball_components/lib/src/components/boundaries.dart +++ b/packages/pinball_components/lib/src/components/boundaries.dart @@ -26,11 +26,10 @@ class _BottomBoundary extends BodyComponent with InitialPosition { /// {@macro bottom_boundary} _BottomBoundary() : super( + renderBody: false, priority: RenderPriority.bottomBoundary, children: [_BottomBoundarySpriteComponent()], - ) { - renderBody = false; - } + ); List _createFixtureDefs() { final bottomLeftCurve = BezierCurveShape( @@ -92,13 +91,10 @@ class _OuterBoundary extends BodyComponent with InitialPosition { /// {@macro outer_boundary} _OuterBoundary() : super( + renderBody: false, priority: RenderPriority.outerBoundary, - children: [ - _OuterBoundarySpriteComponent(), - ], - ) { - renderBody = false; - } + children: [_OuterBoundarySpriteComponent()], + ); List _createFixtureDefs() { final topWall = EdgeShape() @@ -106,28 +102,59 @@ class _OuterBoundary extends BodyComponent with InitialPosition { Vector2(3.6, -70.2), Vector2(-14.1, -70.2), ); - final topWallFixtureDef = FixtureDef(topWall); final topLeftCurve = BezierCurveShape( controlPoints: [ - Vector2(-32.3, -57.2), + topWall.vertex1, Vector2(-31.5, -69.9), - Vector2(-14.1, -70.2), + Vector2(-32.3, -57.2), ], ); - final topLeftCurveFixtureDef = FixtureDef(topLeftCurve); - final leftWall = EdgeShape() + final topLeftWall = EdgeShape() ..set( - Vector2(-32.3, -57.2), + topLeftCurve.vertices.last, + Vector2(-33.5, -44), + ); + + final upperLeftWallCurve = BezierCurveShape( + controlPoints: [ + topLeftWall.vertex1, + Vector2(-33.9, -40.7), + Vector2(-32.5, -39), + ], + ); + + final middleLeftWallCurve = BezierCurveShape( + controlPoints: [ + upperLeftWallCurve.vertices.last, + Vector2(-23.2, -31.4), + Vector2(-33.9, -21.8), + ], + ); + + final lowerLeftWallCurve = BezierCurveShape( + controlPoints: [ + middleLeftWallCurve.vertices.last, + Vector2(-32.4, -17.6), + Vector2(-37.3, -11), + ], + ); + + final bottomLeftWall = EdgeShape() + ..set( + lowerLeftWallCurve.vertices.last, Vector2(-43.9, 41.8), ); - final leftWallFixtureDef = FixtureDef(leftWall); return [ - topWallFixtureDef, - topLeftCurveFixtureDef, - leftWallFixtureDef, + FixtureDef(topWall), + FixtureDef(topLeftCurve), + FixtureDef(topLeftWall), + FixtureDef(upperLeftWallCurve), + FixtureDef(middleLeftWallCurve), + FixtureDef(lowerLeftWallCurve), + FixtureDef(bottomLeftWall), ]; } diff --git a/packages/pinball_components/lib/src/components/bumping_behavior.dart b/packages/pinball_components/lib/src/components/bumping_behavior.dart new file mode 100644 index 00000000..654f96b4 --- /dev/null +++ b/packages/pinball_components/lib/src/components/bumping_behavior.dart @@ -0,0 +1,25 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template bumping_behavior} +/// Makes any [BodyComponent] that contacts with [parent] bounce off. +/// {@endtemplate} +class BumpingBehavior extends ContactBehavior { + /// {@macro bumping_behavior} + BumpingBehavior({required double strength}) : _strength = strength; + + /// Determines how strong the bump is. + final double _strength; + + @override + void postSolve(Object other, Contact contact, ContactImpulse impulse) { + super.postSolve(other, contact, impulse); + if (other is! BodyComponent) return; + + other.body.applyLinearImpulse( + contact.manifold.localPoint + ..normalize() + ..multiply(Vector2.all(other.body.mass * _strength)), + ); + } +} diff --git a/packages/pinball_components/lib/src/components/chrome_dino.dart b/packages/pinball_components/lib/src/components/chrome_dino.dart index 4b14e673..e1a1a1fc 100644 --- a/packages/pinball_components/lib/src/components/chrome_dino.dart +++ b/packages/pinball_components/lib/src/components/chrome_dino.dart @@ -12,9 +12,11 @@ import 'package:pinball_components/pinball_components.dart'; /// {@endtemplate} class ChromeDino extends BodyComponent with InitialPosition { /// {@macro chrome_dino} - ChromeDino() : super(priority: RenderPriority.dino) { - renderBody = false; - } + ChromeDino() + : super( + priority: RenderPriority.dino, + renderBody: false, + ); /// The size of the dinosaur mouth. static final size = Vector2(5.5, 5); @@ -22,8 +24,10 @@ class ChromeDino extends BodyComponent with InitialPosition { /// Anchors the [ChromeDino] to the [RevoluteJoint] that controls its arc /// motion. Future<_ChromeDinoJoint> _anchorToJoint() async { + // TODO(allisonryan0002): try moving to anchor after new body is defined. final anchor = _ChromeDinoAnchor() ..initialPosition = initialPosition + Vector2(9, -4); + await add(anchor); final jointDef = _ChromeDinoAnchorRevoluteJointDef( @@ -40,9 +44,11 @@ class ChromeDino extends BodyComponent with InitialPosition { Future onLoad() async { await super.onLoad(); final joint = await _anchorToJoint(); + const framesInAnimation = 98; + const animationFPS = 1 / 24; await add( TimerComponent( - period: 98 / 48, + period: (framesInAnimation / 2) * animationFPS, onTick: joint._swivel, repeat: true, ), @@ -81,13 +87,11 @@ class ChromeDino extends BodyComponent with InitialPosition { } } -/// {@template chrome_dino_anchor} -/// [JointAnchor] positioned at the back of the [ChromeDino]. -/// {@endtemplate} class _ChromeDinoAnchor extends JointAnchor { - /// {@macro chrome_dino_anchor} _ChromeDinoAnchor(); + // TODO(allisonryan0002): if these aren't moved when fixing the rendering, see + // if the joint can be created in onMount to resolve render syncing. @override Future onLoad() async { await super.onLoad(); @@ -113,9 +117,8 @@ class _ChromeDinoAnchorRevoluteJointDef extends RevoluteJointDef { chromeDino.body.position + anchor.body.position, ); enableLimit = true; - const angle = _ChromeDinoJoint._halfSweepingAngle; - lowerAngle = -angle; - upperAngle = angle; + lowerAngle = -_ChromeDinoJoint._halfSweepingAngle; + upperAngle = _ChromeDinoJoint._halfSweepingAngle; enableMotor = true; maxMotorTorque = chromeDino.body.mass * 255; @@ -126,7 +129,6 @@ class _ChromeDinoAnchorRevoluteJointDef extends RevoluteJointDef { class _ChromeDinoJoint extends RevoluteJoint { _ChromeDinoJoint(_ChromeDinoAnchorRevoluteJointDef def) : super(def); - /// Half the angle of the arc motion. static const _halfSweepingAngle = 0.1143; /// Sweeps the [ChromeDino] up and down repeatedly. diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 57e93abb..c6c5c802 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -1,4 +1,4 @@ -export 'alien_bumper.dart'; +export 'android_bumper/android_bumper.dart'; export 'backboard/backboard.dart'; export 'ball.dart'; export 'baseboard.dart'; @@ -8,11 +8,11 @@ export 'boundaries.dart'; export 'camera_zoom.dart'; export 'chrome_dino.dart'; export 'dash_animatronic.dart'; -export 'dash_nest_bumper.dart'; +export 'dash_nest_bumper/dash_nest_bumper.dart'; export 'dino_walls.dart'; export 'fire_effect.dart'; export 'flipper.dart'; -export 'google_letter.dart'; +export 'google_letter/google_letter.dart'; export 'initial_position.dart'; export 'joint_anchor.dart'; export 'kicker.dart'; @@ -30,5 +30,5 @@ export 'spaceship.dart'; export 'spaceship_rail.dart'; export 'spaceship_ramp.dart'; export 'sparky_animatronic.dart'; -export 'sparky_bumper.dart'; +export 'sparky_bumper/sparky_bumper.dart'; export 'sparky_computer.dart'; diff --git a/packages/pinball_components/lib/src/components/dash_animatronic.dart b/packages/pinball_components/lib/src/components/dash_animatronic.dart index 47e1e08f..faa604e9 100644 --- a/packages/pinball_components/lib/src/components/dash_animatronic.dart +++ b/packages/pinball_components/lib/src/components/dash_animatronic.dart @@ -10,7 +10,6 @@ class DashAnimatronic extends SpriteAnimationComponent with HasGameRef { : super( anchor: Anchor.center, playing: false, - priority: RenderPriority.dashAnimatronic, ); @override diff --git a/packages/pinball_components/lib/src/components/dash_nest_bumper/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/dash_nest_bumper/behaviors/behaviors.dart new file mode 100644 index 00000000..839cbd67 --- /dev/null +++ b/packages/pinball_components/lib/src/components/dash_nest_bumper/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'dash_nest_bumper_contact_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/dash_nest_bumper/behaviors/dash_nest_bumper_contact_behavior.dart b/packages/pinball_components/lib/src/components/dash_nest_bumper/behaviors/dash_nest_bumper_contact_behavior.dart new file mode 100644 index 00000000..829229e4 --- /dev/null +++ b/packages/pinball_components/lib/src/components/dash_nest_bumper/behaviors/dash_nest_bumper_contact_behavior.dart @@ -0,0 +1,15 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class DashNestBumperBallContactBehavior + extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + parent.bloc.onBallContacted(); + } +} diff --git a/packages/pinball_components/lib/src/components/dash_nest_bumper/cubit/dash_nest_bumper_cubit.dart b/packages/pinball_components/lib/src/components/dash_nest_bumper/cubit/dash_nest_bumper_cubit.dart new file mode 100644 index 00000000..8fc6b157 --- /dev/null +++ b/packages/pinball_components/lib/src/components/dash_nest_bumper/cubit/dash_nest_bumper_cubit.dart @@ -0,0 +1,19 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; + +part 'dash_nest_bumper_state.dart'; + +class DashNestBumperCubit extends Cubit { + DashNestBumperCubit() : super(DashNestBumperState.inactive); + + /// Event added when the bumper contacts with a ball. + void onBallContacted() { + emit(DashNestBumperState.active); + } + + /// Event added when the bumper should return to its initial configuration. + void onReset() { + emit(DashNestBumperState.inactive); + } +} diff --git a/packages/pinball_components/lib/src/components/dash_nest_bumper/cubit/dash_nest_bumper_state.dart b/packages/pinball_components/lib/src/components/dash_nest_bumper/cubit/dash_nest_bumper_state.dart new file mode 100644 index 00000000..c169069f --- /dev/null +++ b/packages/pinball_components/lib/src/components/dash_nest_bumper/cubit/dash_nest_bumper_state.dart @@ -0,0 +1,10 @@ +part of 'dash_nest_bumper_cubit.dart'; + +/// Indicates the [DashNestBumperCubit]'s current state. +enum DashNestBumperState { + /// A lit up bumper. + active, + + /// A dimmed bumper. + inactive, +} diff --git a/packages/pinball_components/lib/src/components/dash_nest_bumper.dart b/packages/pinball_components/lib/src/components/dash_nest_bumper/dash_nest_bumper.dart similarity index 63% rename from packages/pinball_components/lib/src/components/dash_nest_bumper.dart rename to packages/pinball_components/lib/src/components/dash_nest_bumper/dash_nest_bumper.dart index 46f96b37..82ec0036 100644 --- a/packages/pinball_components/lib/src/components/dash_nest_bumper.dart +++ b/packages/pinball_components/lib/src/components/dash_nest_bumper/dash_nest_bumper.dart @@ -4,6 +4,10 @@ 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/dash_nest_bumper/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'cubit/dash_nest_bumper_cubit.dart'; /// {@template dash_nest_bumper} /// Bumper with a nest appearance. @@ -16,54 +20,87 @@ class DashNestBumper extends BodyComponent with InitialPosition { required String activeAssetPath, required String inactiveAssetPath, required Vector2 spritePosition, + Iterable? children, + required this.bloc, }) : _majorRadius = majorRadius, _minorRadius = minorRadius, super( - priority: RenderPriority.dashBumper, + renderBody: false, children: [ _DashNestBumperSpriteGroupComponent( activeAssetPath: activeAssetPath, inactiveAssetPath: inactiveAssetPath, position: spritePosition, + current: bloc.state, ), + DashNestBumperBallContactBehavior(), + ...?children, ], - ) { - renderBody = false; - } + ); /// {@macro dash_nest_bumper} - DashNestBumper.main() - : this._( + DashNestBumper.main({ + Iterable? children, + }) : this._( majorRadius: 5.1, minorRadius: 3.75, activeAssetPath: Assets.images.dash.bumper.main.active.keyName, inactiveAssetPath: Assets.images.dash.bumper.main.inactive.keyName, spritePosition: Vector2(0, -0.3), + children: children, + bloc: DashNestBumperCubit(), ); /// {@macro dash_nest_bumper} - DashNestBumper.a() - : this._( + DashNestBumper.a({ + Iterable? children, + }) : this._( majorRadius: 3, minorRadius: 2.5, activeAssetPath: Assets.images.dash.bumper.a.active.keyName, inactiveAssetPath: Assets.images.dash.bumper.a.inactive.keyName, spritePosition: Vector2(0.35, -1.2), + children: children, + bloc: DashNestBumperCubit(), ); /// {@macro dash_nest_bumper} - DashNestBumper.b() - : this._( + DashNestBumper.b({ + Iterable? children, + }) : this._( majorRadius: 3, minorRadius: 2.5, activeAssetPath: Assets.images.dash.bumper.b.active.keyName, inactiveAssetPath: Assets.images.dash.bumper.b.inactive.keyName, spritePosition: Vector2(0.35, -1.2), + children: children, + bloc: DashNestBumperCubit(), ); + /// Creates an [DashNestBumper] without any children. + /// + /// This can be used for testing [DashNestBumper]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + DashNestBumper.test({required this.bloc}) + : _majorRadius = 3, + _minorRadius = 2.5; + final double _majorRadius; final double _minorRadius; + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final DashNestBumperCubit bloc; + + @override + void onRemove() { + bloc.close(); + super.onRemove(); + } + @override Body createBody() { final shape = EllipseShape( @@ -79,41 +116,22 @@ class DashNestBumper extends BodyComponent with InitialPosition { return world.createBody(bodyDef)..createFixture(fixtureDef); } - - /// Activates the [DashNestBumper]. - void activate() { - firstChild<_DashNestBumperSpriteGroupComponent>()?.current = - DashNestBumperSpriteState.active; - } - - /// Deactivates the [DashNestBumper]. - void deactivate() { - firstChild<_DashNestBumperSpriteGroupComponent>()?.current = - DashNestBumperSpriteState.inactive; - } -} - -/// Indicates the [DashNestBumper]'s current sprite state. -@visibleForTesting -enum DashNestBumperSpriteState { - /// A lit up bumper. - active, - - /// A dimmed bumper. - inactive, } class _DashNestBumperSpriteGroupComponent - extends SpriteGroupComponent with HasGameRef { + extends SpriteGroupComponent + with HasGameRef, ParentIsA { _DashNestBumperSpriteGroupComponent({ required String activeAssetPath, required String inactiveAssetPath, required Vector2 position, + required DashNestBumperState current, }) : _activeAssetPath = activeAssetPath, _inactiveAssetPath = inactiveAssetPath, super( anchor: Anchor.center, position: position, + current: current, ); final String _activeAssetPath; @@ -122,15 +140,15 @@ class _DashNestBumperSpriteGroupComponent @override Future onLoad() async { await super.onLoad(); + parent.bloc.stream.listen((state) => current = state); + final sprites = { - DashNestBumperSpriteState.active: + DashNestBumperState.active: Sprite(gameRef.images.fromCache(_activeAssetPath)), - DashNestBumperSpriteState.inactive: + DashNestBumperState.inactive: Sprite(gameRef.images.fromCache(_inactiveAssetPath)), }; this.sprites = sprites; - - current = DashNestBumperSpriteState.inactive; size = sprites[current]!.originalSize / 10; } } diff --git a/packages/pinball_components/lib/src/components/dino_walls.dart b/packages/pinball_components/lib/src/components/dino_walls.dart index acf124c6..39824490 100644 --- a/packages/pinball_components/lib/src/components/dino_walls.dart +++ b/packages/pinball_components/lib/src/components/dino_walls.dart @@ -29,9 +29,8 @@ class _DinoTopWall extends BodyComponent with InitialPosition { : super( priority: RenderPriority.dinoTopWall, children: [_DinoTopWallSpriteComponent()], - ) { - renderBody = false; - } + renderBody: false, + ); List _createFixtureDefs() { final topStraightShape = EdgeShape() @@ -39,7 +38,6 @@ class _DinoTopWall extends BodyComponent with InitialPosition { Vector2(28.65, -34.3), Vector2(29.5, -34.3), ); - final topStraightFixtureDef = FixtureDef(topStraightShape); final topCurveShape = BezierCurveShape( controlPoints: [ @@ -48,7 +46,6 @@ class _DinoTopWall extends BodyComponent with InitialPosition { Vector2(26.6, -20.2), ], ); - final topCurveFixtureDef = FixtureDef(topCurveShape); final middleCurveShape = BezierCurveShape( controlPoints: [ @@ -57,7 +54,6 @@ class _DinoTopWall extends BodyComponent with InitialPosition { Vector2(26.8, -18.7), ], ); - final middleCurveFixtureDef = FixtureDef(middleCurveShape); final bottomCurveShape = BezierCurveShape( controlPoints: [ @@ -66,21 +62,19 @@ class _DinoTopWall extends BodyComponent with InitialPosition { Vector2(27, -14.2), ], ); - final bottomCurveFixtureDef = FixtureDef(bottomCurveShape); final bottomStraightShape = EdgeShape() ..set( bottomCurveShape.vertices.last, Vector2(31, -13.7), ); - final bottomStraightFixtureDef = FixtureDef(bottomStraightShape); return [ - topStraightFixtureDef, - topCurveFixtureDef, - middleCurveFixtureDef, - bottomCurveFixtureDef, - bottomStraightFixtureDef, + FixtureDef(topStraightShape), + FixtureDef(topCurveShape), + FixtureDef(middleCurveShape), + FixtureDef(bottomCurveShape), + FixtureDef(bottomStraightShape), ]; } @@ -128,22 +122,15 @@ class _DinoBottomWall extends BodyComponent with InitialPosition { : super( priority: RenderPriority.dinoBottomWall, children: [_DinoBottomWallSpriteComponent()], - ) { - renderBody = false; - } + renderBody: false, + ); List _createFixtureDefs() { - const restitution = 1.0; - final topStraightShape = EdgeShape() ..set( Vector2(32.4, -8.8), Vector2(25, -7.7), ); - final topStraightFixtureDef = FixtureDef( - topStraightShape, - restitution: restitution, - ); final topLeftCurveShape = BezierCurveShape( controlPoints: [ @@ -152,36 +139,24 @@ class _DinoBottomWall extends BodyComponent with InitialPosition { Vector2(29.8, 13.8), ], ); - final topLeftCurveFixtureDef = FixtureDef( - topLeftCurveShape, - restitution: restitution, - ); final bottomLeftStraightShape = EdgeShape() ..set( topLeftCurveShape.vertices.last, Vector2(31.9, 44.1), ); - final bottomLeftStraightFixtureDef = FixtureDef( - bottomLeftStraightShape, - restitution: restitution, - ); final bottomStraightShape = EdgeShape() ..set( bottomLeftStraightShape.vertex2, Vector2(37.8, 44.1), ); - final bottomStraightFixtureDef = FixtureDef( - bottomStraightShape, - restitution: restitution, - ); return [ - topStraightFixtureDef, - topLeftCurveFixtureDef, - bottomLeftStraightFixtureDef, - bottomStraightFixtureDef, + FixtureDef(topStraightShape), + FixtureDef(topLeftCurveShape), + FixtureDef(bottomLeftStraightShape), + FixtureDef(bottomStraightShape), ]; } diff --git a/packages/pinball_components/lib/src/components/flipper.dart b/packages/pinball_components/lib/src/components/flipper.dart index bd826668..dccd7ce7 100644 --- a/packages/pinball_components/lib/src/components/flipper.dart +++ b/packages/pinball_components/lib/src/components/flipper.dart @@ -14,10 +14,9 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { Flipper({ required this.side, }) : super( + renderBody: false, children: [_FlipperSpriteComponent(side: side)], - ) { - renderBody = false; - } + ); /// The size of the [Flipper]. static final size = Vector2(13.5, 4.3); diff --git a/packages/pinball_components/lib/src/components/google_letter/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/google_letter/behaviors/behaviors.dart new file mode 100644 index 00000000..df54c1f4 --- /dev/null +++ b/packages/pinball_components/lib/src/components/google_letter/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'google_letter_ball_contact_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/google_letter/behaviors/google_letter_ball_contact_behavior.dart b/packages/pinball_components/lib/src/components/google_letter/behaviors/google_letter_ball_contact_behavior.dart new file mode 100644 index 00000000..c3f0423e --- /dev/null +++ b/packages/pinball_components/lib/src/components/google_letter/behaviors/google_letter_ball_contact_behavior.dart @@ -0,0 +1,14 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class GoogleLetterBallContactBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + parent.bloc.onBallContacted(); + } +} diff --git a/packages/pinball_components/lib/src/components/google_letter/cubit/google_letter_cubit.dart b/packages/pinball_components/lib/src/components/google_letter/cubit/google_letter_cubit.dart new file mode 100644 index 00000000..a352e98d --- /dev/null +++ b/packages/pinball_components/lib/src/components/google_letter/cubit/google_letter_cubit.dart @@ -0,0 +1,17 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; + +part 'google_letter_state.dart'; + +class GoogleLetterCubit extends Cubit { + GoogleLetterCubit() : super(GoogleLetterState.inactive); + + void onBallContacted() { + emit(GoogleLetterState.active); + } + + void onReset() { + emit(GoogleLetterState.inactive); + } +} diff --git a/packages/pinball_components/lib/src/components/google_letter/cubit/google_letter_state.dart b/packages/pinball_components/lib/src/components/google_letter/cubit/google_letter_state.dart new file mode 100644 index 00000000..e1339320 --- /dev/null +++ b/packages/pinball_components/lib/src/components/google_letter/cubit/google_letter_state.dart @@ -0,0 +1,10 @@ +part of 'google_letter_cubit.dart'; + +/// Indicates the [GoogleLetterCubit]'s current state. +enum GoogleLetterState { + /// A lit up letter. + active, + + /// A dimmed letter. + inactive, +} diff --git a/packages/pinball_components/lib/src/components/google_letter.dart b/packages/pinball_components/lib/src/components/google_letter/google_letter.dart similarity index 52% rename from packages/pinball_components/lib/src/components/google_letter.dart rename to packages/pinball_components/lib/src/components/google_letter/google_letter.dart index 43a4c113..63207e01 100644 --- a/packages/pinball_components/lib/src/components/google_letter.dart +++ b/packages/pinball_components/lib/src/components/google_letter/google_letter.dart @@ -1,33 +1,46 @@ import 'package:flame/components.dart'; -import 'package:flame/effects.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/google_letter/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'cubit/google_letter_cubit.dart'; /// {@template google_letter} /// Circular sensor that represents a letter in "GOOGLE" for a given index. /// {@endtemplate} class GoogleLetter extends BodyComponent with InitialPosition { /// {@macro google_letter} - GoogleLetter(int index) - : _sprite = _GoogleLetterSprite( - _GoogleLetterSprite.spritePaths[index], + GoogleLetter( + int index, + ) : bloc = GoogleLetterCubit(), + super( + children: [ + GoogleLetterBallContactBehavior(), + _GoogleLetterSprite(_GoogleLetterSprite.spritePaths[index]) + ], ); - final _GoogleLetterSprite _sprite; - - /// Activates this [GoogleLetter]. - // TODO(alestiago): Improve doc comment once activate and deactivate - // are implemented with the actual assets. - Future activate() => _sprite.activate(); + /// Creates a [GoogleLetter] without any children. + /// + /// This can be used for testing [GoogleLetter]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + GoogleLetter.test({ + required this.bloc, + }); - /// Deactivates this [GoogleLetter]. - Future deactivate() => _sprite.deactivate(); + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final GoogleLetterCubit bloc; @override - Future onLoad() async { - await super.onLoad(); - await add(_sprite); + void onRemove() { + bloc.close(); + super.onRemove(); } @override @@ -46,8 +59,11 @@ class GoogleLetter extends BodyComponent with InitialPosition { } } -class _GoogleLetterSprite extends SpriteComponent with HasGameRef { - _GoogleLetterSprite(String path) : _path = path; +class _GoogleLetterSprite extends SpriteComponent + with HasGameRef, ParentIsA { + _GoogleLetterSprite(String path) + : _path = path, + super(anchor: Anchor.center); static final spritePaths = [ Assets.images.googleWord.letter1.keyName, @@ -60,39 +76,16 @@ class _GoogleLetterSprite extends SpriteComponent with HasGameRef { final String _path; - // TODO(alestiago): Correctly implement activate and deactivate once the - // assets are provided. - Future activate() async { - await add( - _GoogleLetterColorEffect(color: Colors.green), - ); - } - - Future deactivate() async { - await add( - _GoogleLetterColorEffect(color: Colors.red), - ); - } - @override Future onLoad() async { await super.onLoad(); + // TODO(alisonryan2002): Make SpriteGroupComponent. + // parent.bloc.stream.listen(); // TODO(alestiago): Used cached assets. final sprite = await gameRef.loadSprite(_path); this.sprite = sprite; // TODO(alestiago): Size correctly once the assets are provided. size = sprite.originalSize / 5; - anchor = Anchor.center; } } - -class _GoogleLetterColorEffect extends ColorEffect { - _GoogleLetterColorEffect({ - required Color color, - }) : super( - color, - const Offset(0, 1), - EffectController(duration: 0.25), - ); -} diff --git a/packages/pinball_components/lib/src/components/kicker.dart b/packages/pinball_components/lib/src/components/kicker.dart index f6963d7c..12cd638d 100644 --- a/packages/pinball_components/lib/src/components/kicker.dart +++ b/packages/pinball_components/lib/src/components/kicker.dart @@ -19,9 +19,8 @@ class Kicker extends BodyComponent with InitialPosition { }) : _side = side, super( children: [_KickerSpriteComponent(side: side)], - ) { - renderBody = false; - } + renderBody: false, + ); /// The size of the [Kicker] body. static final Vector2 size = Vector2(4.4, 15); diff --git a/packages/pinball_components/lib/src/components/launch_ramp.dart b/packages/pinball_components/lib/src/components/launch_ramp.dart index baa54744..13f063b6 100644 --- a/packages/pinball_components/lib/src/components/launch_ramp.dart +++ b/packages/pinball_components/lib/src/components/launch_ramp.dart @@ -32,13 +32,13 @@ class _LaunchRampBase extends BodyComponent with Layered { _LaunchRampBase() : super( priority: RenderPriority.launchRamp, + renderBody: false, children: [ _LaunchRampBackgroundRailingSpriteComponent(), _LaunchRampBaseSpriteComponent(), ], ) { layer = Layer.launcher; - renderBody = false; } // TODO(ruimiguel): final asset differs slightly from the current shape. We @@ -107,13 +107,6 @@ class _LaunchRampBase extends BodyComponent with Layered { return body; } - - @override - Future onLoad() async { - await super.onLoad(); - gameRef - .addContactCallback(LayerSensorBallContactCallback<_LaunchRampExit>()); - } } class _LaunchRampBaseSpriteComponent extends SpriteComponent with HasGameRef { @@ -157,9 +150,8 @@ class _LaunchRampForegroundRailing extends BodyComponent { : super( priority: RenderPriority.launchRampForegroundRailing, children: [_LaunchRampForegroundRailingSpriteComponent()], - ) { - renderBody = false; - } + renderBody: false, + ); List _createFixtureDefs() { final fixturesDef = []; @@ -218,9 +210,8 @@ class _LaunchRampForegroundRailingSpriteComponent extends SpriteComponent } class _LaunchRampCloseWall extends BodyComponent with InitialPosition, Layered { - _LaunchRampCloseWall() { + _LaunchRampCloseWall() : super(renderBody: false) { layer = Layer.board; - renderBody = false; } @override @@ -252,7 +243,6 @@ class _LaunchRampExit extends LayerSensor { outsidePriority: RenderPriority.ballOnBoard, ) { layer = Layer.launcher; - renderBody = false; } static final Vector2 _size = Vector2(1.6, 0.1); diff --git a/packages/pinball_components/lib/src/components/layer_sensor.dart b/packages/pinball_components/lib/src/components/layer_sensor.dart index 85cc8506..7a749357 100644 --- a/packages/pinball_components/lib/src/components/layer_sensor.dart +++ b/packages/pinball_components/lib/src/components/layer_sensor.dart @@ -17,13 +17,11 @@ enum LayerEntranceOrientation { /// {@template layer_sensor} /// [BodyComponent] located at the entrance and exit of a [Layer]. /// -/// [LayerSensorBallContactCallback] detects when a [Ball] passes -/// through this sensor. -/// /// By default the base [layer] is set to [Layer.board] and the /// [outsidePriority] is set to the lowest possible [Layer]. /// {@endtemplate} -abstract class LayerSensor extends BodyComponent with InitialPosition, Layered { +abstract class LayerSensor extends BodyComponent + with InitialPosition, Layered, ContactCallbacks { /// {@macro layer_sensor} LayerSensor({ required Layer insideLayer, @@ -34,7 +32,8 @@ abstract class LayerSensor extends BodyComponent with InitialPosition, Layered { }) : _insideLayer = insideLayer, _outsideLayer = outsideLayer ?? Layer.board, _insidePriority = insidePriority, - _outsidePriority = outsidePriority ?? RenderPriority.ballOnBoard { + _outsidePriority = outsidePriority ?? RenderPriority.ballOnBoard, + super(renderBody: false) { layer = Layer.opening; } final Layer _insideLayer; @@ -75,35 +74,29 @@ abstract class LayerSensor extends BodyComponent with InitialPosition, Layered { return world.createBody(bodyDef)..createFixture(fixtureDef); } -} -/// {@template layer_sensor_ball_contact_callback} -/// Detects when a [Ball] enters or exits a [Layer] through a [LayerSensor]. -/// -/// Modifies [Ball]'s [Layer] and render priority depending on whether the -/// [Ball] is on or outside of a [Layer]. -/// {@endtemplate} -class LayerSensorBallContactCallback - extends ContactCallback { @override - void begin(Ball ball, LayerEntrance layerEntrance, Contact _) { - if (ball.layer != layerEntrance.insideLayer) { + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + + if (other.layer != insideLayer) { final isBallEnteringOpening = - (layerEntrance.orientation == LayerEntranceOrientation.down && - ball.body.linearVelocity.y < 0) || - (layerEntrance.orientation == LayerEntranceOrientation.up && - ball.body.linearVelocity.y > 0); + (orientation == LayerEntranceOrientation.down && + other.body.linearVelocity.y < 0) || + (orientation == LayerEntranceOrientation.up && + other.body.linearVelocity.y > 0); if (isBallEnteringOpening) { - ball - ..layer = layerEntrance.insideLayer - ..priority = layerEntrance.insidePriority + other + ..layer = insideLayer + ..priority = insidePriority ..reorderChildren(); } } else { - ball - ..layer = layerEntrance.outsideLayer - ..priority = layerEntrance.outsidePriority + other + ..layer = outsideLayer + ..priority = outsidePriority ..reorderChildren(); } } diff --git a/packages/pinball_components/lib/src/components/plunger.dart b/packages/pinball_components/lib/src/components/plunger.dart index 295c799d..735a5490 100644 --- a/packages/pinball_components/lib/src/components/plunger.dart +++ b/packages/pinball_components/lib/src/components/plunger.dart @@ -14,9 +14,11 @@ class Plunger extends BodyComponent with InitialPosition, Layered { required this.compressionDistance, // TODO(ruimiguel): set to priority +1 over LaunchRamp once all priorities // are fixed. - }) : super(priority: RenderPriority.plunger) { + }) : super( + priority: RenderPriority.plunger, + renderBody: false, + ) { layer = Layer.launcher; - renderBody = false; } /// Distance the plunger can lower. diff --git a/packages/pinball_components/lib/src/components/render_priority.dart b/packages/pinball_components/lib/src/components/render_priority.dart index d2438db9..395ca49c 100644 --- a/packages/pinball_components/lib/src/components/render_priority.dart +++ b/packages/pinball_components/lib/src/components/render_priority.dart @@ -24,7 +24,7 @@ abstract class RenderPriority { static const int ballOnSpaceship = _above + spaceshipSaucer; /// Render priority for the [Ball] while it's on the [SpaceshipRail]. - static const int ballOnSpaceshipRail = _below + spaceshipSaucer; + static const int ballOnSpaceshipRail = _above + spaceshipRail; /// Render priority for the [Ball] while it's on the [LaunchRamp]. static const int ballOnLaunchRamp = _above + launchRamp; @@ -69,11 +69,7 @@ abstract class RenderPriority { // Flutter Forest - static const int signpost = _above + launchRampForegroundRailing; - - static const int dashBumper = _above + ballOnBoard; - - static const int dashAnimatronic = 2 * _above + launchRamp; + static const int flutterForest = _above + launchRampForegroundRailing; // Sparky Fire Zone @@ -87,13 +83,13 @@ abstract class RenderPriority { static const int turboChargeFlame = _above + ballOnBoard; - // Android Spaceship + // Android Acres static const int spaceshipRail = _above + bottomGroup; - static const int spaceshipRailForeground = _above + spaceshipRail; + static const int spaceshipRailExit = _above + ballOnSpaceshipRail; - static const int spaceshipSaucer = _above + spaceshipRail; + static const int spaceshipSaucer = _above + ballOnSpaceshipRail; static const int spaceshipSaucerWall = _above + spaceshipSaucer; @@ -110,7 +106,7 @@ abstract class RenderPriority { static const int spaceshipRampBoardOpening = _below + ballOnBoard; - static const int alienBumper = _above + ballOnBoard; + static const int androidBumper = _above + ballOnBoard; // Score Text diff --git a/packages/pinball_components/lib/src/components/signpost.dart b/packages/pinball_components/lib/src/components/signpost.dart index 175c3382..13425342 100644 --- a/packages/pinball_components/lib/src/components/signpost.dart +++ b/packages/pinball_components/lib/src/components/signpost.dart @@ -46,13 +46,15 @@ extension on SignpostSpriteState { /// {@endtemplate} class Signpost extends BodyComponent with InitialPosition { /// {@macro signpost} - Signpost() - : super( - priority: RenderPriority.signpost, - children: [_SignpostSpriteComponent()], - ) { - renderBody = false; - } + Signpost({ + Iterable? children, + }) : super( + renderBody: false, + children: [ + _SignpostSpriteComponent(), + ...?children, + ], + ); /// Forwards the sprite to the next [SignpostSpriteState]. /// diff --git a/packages/pinball_components/lib/src/components/slingshot.dart b/packages/pinball_components/lib/src/components/slingshot.dart index 35346584..b48bf2f9 100644 --- a/packages/pinball_components/lib/src/components/slingshot.dart +++ b/packages/pinball_components/lib/src/components/slingshot.dart @@ -40,9 +40,8 @@ class Slingshot extends BodyComponent with InitialPosition { super( priority: RenderPriority.slingshot, children: [_SlinghsotSpriteComponent(spritePath, angle: angle)], - ) { - renderBody = false; - } + renderBody: false, + ); final double _length; diff --git a/packages/pinball_components/lib/src/components/spaceship.dart b/packages/pinball_components/lib/src/components/spaceship.dart index 4ea4e05a..a52df81d 100644 --- a/packages/pinball_components/lib/src/components/spaceship.dart +++ b/packages/pinball_components/lib/src/components/spaceship.dart @@ -42,25 +42,12 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition, Layered { SpaceshipSaucer() : super( priority: RenderPriority.spaceshipSaucer, + renderBody: false, children: [ _SpaceshipSaucerSpriteComponent(), ], ) { layer = Layer.spaceship; - renderBody = false; - } - - @override - Future onLoad() async { - await super.onLoad(); - - gameRef - ..addContactCallback( - LayerSensorBallContactCallback<_SpaceshipEntrance>(), - ) - ..addContactCallback( - LayerSensorBallContactCallback<_SpaceshipHole>(), - ); } @override @@ -108,8 +95,8 @@ class AndroidHead extends BodyComponent with InitialPosition, Layered { : super( priority: RenderPriority.androidHead, children: [_AndroidHeadSpriteAnimation()], + renderBody: false, ) { - renderBody = false; layer = Layer.spaceship; } @@ -164,7 +151,6 @@ class _SpaceshipEntrance extends LayerSensor { @override Shape get shape { - renderBody = false; final radius = Spaceship.size.y / 2; return PolygonShape() ..setAsEdge( @@ -189,7 +175,6 @@ class _SpaceshipHole extends LayerSensor { insidePriority: RenderPriority.ballOnSpaceship, outsidePriority: outsidePriority, ) { - renderBody = false; layer = Layer.spaceship; } @@ -237,14 +222,16 @@ class _SpaceshipWallShape extends ChainShape { /// {@endtemplate} class SpaceshipWall extends BodyComponent with InitialPosition, Layered { /// {@macro spaceship_wall} - SpaceshipWall() : super(priority: RenderPriority.spaceshipSaucerWall) { + SpaceshipWall() + : super( + priority: RenderPriority.spaceshipSaucerWall, + renderBody: false, + ) { layer = Layer.spaceship; } @override Body createBody() { - renderBody = false; - final shape = _SpaceshipWallShape(); final fixtureDef = FixtureDef(shape); diff --git a/packages/pinball_components/lib/src/components/spaceship_rail.dart b/packages/pinball_components/lib/src/components/spaceship_rail.dart index 1175384b..91540c62 100644 --- a/packages/pinball_components/lib/src/components/spaceship_rail.dart +++ b/packages/pinball_components/lib/src/components/spaceship_rail.dart @@ -2,88 +2,71 @@ import 'dart:math' as math; import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball_components/gen/assets.gen.dart'; -import 'package:pinball_components/pinball_components.dart' hide Assets; +import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; /// {@template spaceship_rail} -/// A [Blueprint] for the spaceship drop tube. +/// A [Blueprint] for the rail exiting the [Spaceship]. /// {@endtemplate} class SpaceshipRail extends Blueprint { /// {@macro spaceship_rail} SpaceshipRail() : super( components: [ - _SpaceshipRailRamp(), + _SpaceshipRail(), _SpaceshipRailExit(), - _SpaceshipRailBase(radius: 0.55) - ..initialPosition = Vector2(-26.15, -18.65), - _SpaceshipRailBase(radius: 0.8) - ..initialPosition = Vector2(-25.5, 12.9), - _SpaceshipRailForeground() + _SpaceshipRailExitSpriteComponent() ], ); } -class _SpaceshipRailRamp extends BodyComponent with Layered { - _SpaceshipRailRamp() +class _SpaceshipRail extends BodyComponent with Layered { + _SpaceshipRail() : super( priority: RenderPriority.spaceshipRail, - children: [_SpaceshipRailRampSpriteComponent()], + children: [_SpaceshipRailSpriteComponent()], + renderBody: false, ) { layer = Layer.spaceshipExitRail; - renderBody = false; } List _createFixtureDefs() { - final fixturesDefs = []; - final topArcShape = ArcShape( - center: Vector2(-35.5, -30.9), + center: Vector2(-35.1, -30.9), arcRadius: 2.5, angle: math.pi, rotation: 0.2, ); - final topArcFixtureDef = FixtureDef(topArcShape); - fixturesDefs.add(topArcFixtureDef); final topLeftCurveShape = BezierCurveShape( controlPoints: [ - Vector2(-37.9, -30.4), - Vector2(-38, -23.9), + Vector2(-37.6, -30.4), + Vector2(-37.8, -23.9), Vector2(-30.93, -18.2), ], ); - final topLeftCurveFixtureDef = FixtureDef(topLeftCurveShape); - fixturesDefs.add(topLeftCurveFixtureDef); final middleLeftCurveShape = BezierCurveShape( controlPoints: [ topLeftCurveShape.vertices.last, Vector2(-22.6, -10.3), - Vector2(-30, -0.2), + Vector2(-29.5, -0.2), ], ); - final middleLeftCurveFixtureDef = FixtureDef(middleLeftCurveShape); - fixturesDefs.add(middleLeftCurveFixtureDef); final bottomLeftCurveShape = BezierCurveShape( controlPoints: [ middleLeftCurveShape.vertices.last, - Vector2(-36, 8.6), - Vector2(-32.04, 18.3), + Vector2(-35.6, 8.6), + Vector2(-31.3, 18.3), ], ); - final bottomLeftCurveFixtureDef = FixtureDef(bottomLeftCurveShape); - fixturesDefs.add(bottomLeftCurveFixtureDef); final topRightStraightShape = EdgeShape() ..set( - Vector2(-33, -31.3), Vector2(-27.2, -21.3), + Vector2(-33, -31.3), ); - final topRightStraightFixtureDef = FixtureDef(topRightStraightShape); - fixturesDefs.add(topRightStraightFixtureDef); final middleRightCurveShape = BezierCurveShape( controlPoints: [ @@ -92,8 +75,6 @@ class _SpaceshipRailRamp extends BodyComponent with Layered { Vector2(-25.29, 1.7), ], ); - final middleRightCurveFixtureDef = FixtureDef(middleRightCurveShape); - fixturesDefs.add(middleRightCurveFixtureDef); final bottomRightCurveShape = BezierCurveShape( controlPoints: [ @@ -102,10 +83,16 @@ class _SpaceshipRailRamp extends BodyComponent with Layered { Vector2(-26.8, 15.7), ], ); - final bottomRightCurveFixtureDef = FixtureDef(bottomRightCurveShape); - fixturesDefs.add(bottomRightCurveFixtureDef); - return fixturesDefs; + return [ + FixtureDef(topArcShape), + FixtureDef(topLeftCurveShape), + FixtureDef(middleLeftCurveShape), + FixtureDef(bottomLeftCurveShape), + FixtureDef(topRightStraightShape), + FixtureDef(middleRightCurveShape), + FixtureDef(bottomRightCurveShape), + ]; } @override @@ -114,67 +101,49 @@ class _SpaceshipRailRamp extends BodyComponent with Layered { _createFixtureDefs().forEach(body.createFixture); return body; } - - @override - Future onLoad() async { - await super.onLoad(); - gameRef.addContactCallback( - LayerSensorBallContactCallback<_SpaceshipRailExit>(), - ); - } } -class _SpaceshipRailRampSpriteComponent extends SpriteComponent - with HasGameRef { +class _SpaceshipRailSpriteComponent extends SpriteComponent with HasGameRef { + _SpaceshipRailSpriteComponent() + : super( + anchor: Anchor.center, + position: Vector2(-29.4, -5.7), + ); + @override Future onLoad() async { await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.spaceship.rail.main.keyName, + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.spaceship.rail.main.keyName, + ), ); this.sprite = sprite; size = sprite.originalSize / 10; - anchor = Anchor.center; - position = Vector2(-29.4, -5.7); } } -class _SpaceshipRailForeground extends SpriteComponent with HasGameRef { - _SpaceshipRailForeground() - : super(priority: RenderPriority.spaceshipRailForeground); +class _SpaceshipRailExitSpriteComponent extends SpriteComponent + with HasGameRef { + _SpaceshipRailExitSpriteComponent() + : super( + anchor: Anchor.center, + position: Vector2(-28, 19.4), + priority: RenderPriority.spaceshipRailExit, + ); @override Future onLoad() async { await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.spaceship.rail.foreground.keyName, + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.spaceship.rail.exit.keyName, + ), ); this.sprite = sprite; size = sprite.originalSize / 10; - anchor = Anchor.center; - position = Vector2(-28.5, 19.7); - } -} - -/// Represents the ground bases of the [_SpaceshipRailRamp]. -class _SpaceshipRailBase extends BodyComponent with InitialPosition { - _SpaceshipRailBase({required this.radius}) { - renderBody = false; - } - - final double radius; - - @override - Body createBody() { - final shape = CircleShape()..radius = radius; - final fixtureDef = FixtureDef(shape); - final bodyDef = BodyDef( - position: initialPosition, - ); - - return world.createBody(bodyDef)..createFixture(fixtureDef); } } @@ -185,7 +154,6 @@ class _SpaceshipRailExit extends LayerSensor { insideLayer: Layer.spaceshipExitRail, insidePriority: RenderPriority.ballOnSpaceshipRail, ) { - renderBody = false; layer = Layer.spaceshipExitRail; } diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp.dart b/packages/pinball_components/lib/src/components/spaceship_ramp.dart index 30211251..c9a1d574 100644 --- a/packages/pinball_components/lib/src/components/spaceship_ramp.dart +++ b/packages/pinball_components/lib/src/components/spaceship_ramp.dart @@ -98,12 +98,12 @@ class _SpaceshipRampBackground extends BodyComponent _SpaceshipRampBackground() : super( priority: RenderPriority.spaceshipRamp, + renderBody: false, children: [ _SpaceshipRampBackgroundRampSpriteComponent(), ], ) { layer = Layer.spaceshipEntranceRamp; - renderBody = false; } /// Width between walls of the ramp. @@ -145,14 +145,6 @@ class _SpaceshipRampBackground extends BodyComponent return body; } - - @override - Future onLoad() async { - await super.onLoad(); - gameRef.addContactCallback( - LayerSensorBallContactCallback<_SpaceshipRampOpening>(), - ); - } } class _SpaceshipRampBackgroundRailingSpriteComponent extends SpriteComponent @@ -255,10 +247,10 @@ class _SpaceshipRampForegroundRailing extends BodyComponent _SpaceshipRampForegroundRailing() : super( priority: RenderPriority.spaceshipRampForegroundRailing, + renderBody: false, children: [_SpaceshipRampForegroundRailingSpriteComponent()], ) { layer = Layer.spaceshipEntranceRamp; - renderBody = false; } List _createFixtureDefs() { @@ -321,8 +313,7 @@ class _SpaceshipRampForegroundRailingSpriteComponent extends SpriteComponent } class _SpaceshipRampBase extends BodyComponent with InitialPosition, Layered { - _SpaceshipRampBase() { - renderBody = false; + _SpaceshipRampBase() : super(renderBody: false) { layer = Layer.board; } @@ -363,9 +354,7 @@ class _SpaceshipRampOpening extends LayerSensor { orientation: LayerEntranceOrientation.down, insidePriority: RenderPriority.ballOnSpaceshipRamp, outsidePriority: outsidePriority, - ) { - renderBody = false; - } + ); final double _rotation; diff --git a/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/behaviors.dart new file mode 100644 index 00000000..faaa510b --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/behaviors.dart @@ -0,0 +1,2 @@ +export 'sparky_bumper_ball_contact_behavior.dart'; +export 'sparky_bumper_blinking_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/sparky_bumper_ball_contact_behavior.dart b/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/sparky_bumper_ball_contact_behavior.dart new file mode 100644 index 00000000..57db300c --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/sparky_bumper_ball_contact_behavior.dart @@ -0,0 +1,14 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class SparkyBumperBallContactBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + parent.bloc.onBallContacted(); + } +} diff --git a/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/sparky_bumper_blinking_behavior.dart b/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/sparky_bumper_blinking_behavior.dart new file mode 100644 index 00000000..81cfa5e1 --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_bumper/behaviors/sparky_bumper_blinking_behavior.dart @@ -0,0 +1,39 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template sparky_bumper_blinking_behavior} +/// Makes a [SparkyBumper] blink back to [SparkyBumperState.active] when +/// [SparkyBumperState.inactive]. +/// {@endtemplate} +class SparkyBumperBlinkingBehavior extends TimerComponent + with ParentIsA { + /// {@macro sparky_bumper_sprite_behavior} + SparkyBumperBlinkingBehavior() : super(period: 0.05); + + void _onNewState(SparkyBumperState state) { + switch (state) { + case SparkyBumperState.active: + break; + case SparkyBumperState.inactive: + timer + ..reset() + ..start(); + break; + } + } + + @override + Future onLoad() async { + await super.onLoad(); + timer.stop(); + parent.bloc.stream.listen(_onNewState); + } + + @override + void onTick() { + super.onTick(); + timer.stop(); + parent.bloc.onBlinked(); + } +} diff --git a/packages/pinball_components/lib/src/components/sparky_bumper/cubit/sparky_bumper_cubit.dart b/packages/pinball_components/lib/src/components/sparky_bumper/cubit/sparky_bumper_cubit.dart new file mode 100644 index 00000000..bbb9b63b --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_bumper/cubit/sparky_bumper_cubit.dart @@ -0,0 +1,17 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; + +part 'sparky_bumper_state.dart'; + +class SparkyBumperCubit extends Cubit { + SparkyBumperCubit() : super(SparkyBumperState.active); + + void onBallContacted() { + emit(SparkyBumperState.inactive); + } + + void onBlinked() { + emit(SparkyBumperState.active); + } +} diff --git a/packages/pinball_components/lib/src/components/sparky_bumper/cubit/sparky_bumper_state.dart b/packages/pinball_components/lib/src/components/sparky_bumper/cubit/sparky_bumper_state.dart new file mode 100644 index 00000000..35cc5ffa --- /dev/null +++ b/packages/pinball_components/lib/src/components/sparky_bumper/cubit/sparky_bumper_state.dart @@ -0,0 +1,10 @@ +part of 'sparky_bumper_cubit.dart'; + +/// Indicates the [SparkyBumperCubit]'s current state. +enum SparkyBumperState { + /// A lit up bumper. + active, + + /// A dimmed bumper. + inactive, +} diff --git a/packages/pinball_components/lib/src/components/sparky_bumper.dart b/packages/pinball_components/lib/src/components/sparky_bumper/sparky_bumper.dart similarity index 57% rename from packages/pinball_components/lib/src/components/sparky_bumper.dart rename to packages/pinball_components/lib/src/components/sparky_bumper/sparky_bumper.dart index becac26b..d6434375 100644 --- a/packages/pinball_components/lib/src/components/sparky_bumper.dart +++ b/packages/pinball_components/lib/src/components/sparky_bumper/sparky_bumper.dart @@ -4,6 +4,10 @@ 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_bumper/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'cubit/sparky_bumper_cubit.dart'; /// {@template sparky_bumper} /// Bumper for Sparky area. @@ -16,58 +20,92 @@ class SparkyBumper extends BodyComponent with InitialPosition { required String onAssetPath, required String offAssetPath, required Vector2 spritePosition, + required this.bloc, + Iterable? children, }) : _majorRadius = majorRadius, _minorRadius = minorRadius, super( priority: RenderPriority.sparkyBumper, + renderBody: false, children: [ + SparkyBumperBallContactBehavior(), + SparkyBumperBlinkingBehavior(), _SparkyBumperSpriteGroupComponent( onAssetPath: onAssetPath, offAssetPath: offAssetPath, position: spritePosition, + state: bloc.state, ), + ...?children, ], - ) { - renderBody = false; - } + ); /// {@macro sparky_bumper} - SparkyBumper.a() - : this._( + SparkyBumper.a({ + Iterable? children, + }) : this._( majorRadius: 2.9, minorRadius: 2.1, onAssetPath: Assets.images.sparky.bumper.a.active.keyName, offAssetPath: Assets.images.sparky.bumper.a.inactive.keyName, spritePosition: Vector2(0, -0.25), + bloc: SparkyBumperCubit(), + children: children, ); /// {@macro sparky_bumper} - SparkyBumper.b() - : this._( + SparkyBumper.b({ + Iterable? children, + }) : this._( majorRadius: 2.85, minorRadius: 2, onAssetPath: Assets.images.sparky.bumper.b.active.keyName, offAssetPath: Assets.images.sparky.bumper.b.inactive.keyName, spritePosition: Vector2(0, -0.35), + bloc: SparkyBumperCubit(), + children: children, ); /// {@macro sparky_bumper} - SparkyBumper.c() - : this._( + SparkyBumper.c({ + Iterable? children, + }) : this._( majorRadius: 3, minorRadius: 2.2, onAssetPath: Assets.images.sparky.bumper.c.active.keyName, offAssetPath: Assets.images.sparky.bumper.c.inactive.keyName, spritePosition: Vector2(0, -0.4), + bloc: SparkyBumperCubit(), + children: children, ); + /// Creates an [SparkyBumper] without any children. + /// + /// This can be used for testing [SparkyBumper]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + SparkyBumper.test({ + required this.bloc, + }) : _majorRadius = 3, + _minorRadius = 2.2; + final double _majorRadius; final double _minorRadius; + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final SparkyBumperCubit bloc; + @override - Body createBody() { - renderBody = false; + void onRemove() { + bloc.close(); + super.onRemove(); + } + @override + Body createBody() { final shape = EllipseShape( center: Vector2.zero(), majorRadius: _majorRadius, @@ -83,37 +121,22 @@ class SparkyBumper extends BodyComponent with InitialPosition { return world.createBody(bodyDef)..createFixture(fixtureDef); } - - /// Animates the [DashNestBumper]. - Future animate() async { - final spriteGroupComponent = firstChild<_SparkyBumperSpriteGroupComponent>() - ?..current = SparkyBumperSpriteState.inactive; - await Future.delayed(const Duration(milliseconds: 50)); - spriteGroupComponent?.current = SparkyBumperSpriteState.active; - } -} - -/// Indicates the [SparkyBumper]'s current sprite state. -@visibleForTesting -enum SparkyBumperSpriteState { - /// A lit up bumper. - active, - - /// A dimmed bumper. - inactive, } class _SparkyBumperSpriteGroupComponent - extends SpriteGroupComponent with HasGameRef { + extends SpriteGroupComponent + with HasGameRef, ParentIsA { _SparkyBumperSpriteGroupComponent({ required String onAssetPath, required String offAssetPath, required Vector2 position, + required SparkyBumperState state, }) : _onAssetPath = onAssetPath, _offAssetPath = offAssetPath, super( anchor: Anchor.center, position: position, + current: state, ); final String _onAssetPath; @@ -122,15 +145,20 @@ class _SparkyBumperSpriteGroupComponent @override Future onLoad() async { await super.onLoad(); + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + parent.bloc.stream.listen((state) => current = state); + final sprites = { - SparkyBumperSpriteState.active: - Sprite(gameRef.images.fromCache(_onAssetPath)), - SparkyBumperSpriteState.inactive: - Sprite(gameRef.images.fromCache(_offAssetPath)), + SparkyBumperState.active: Sprite( + gameRef.images.fromCache(_onAssetPath), + ), + SparkyBumperState.inactive: Sprite( + gameRef.images.fromCache(_offAssetPath), + ), }; this.sprites = sprites; - - current = SparkyBumperSpriteState.active; size = sprites[current]!.originalSize / 10; } } diff --git a/packages/pinball_components/lib/src/components/sparky_computer.dart b/packages/pinball_components/lib/src/components/sparky_computer.dart index 481de63d..7f0cef8c 100644 --- a/packages/pinball_components/lib/src/components/sparky_computer.dart +++ b/packages/pinball_components/lib/src/components/sparky_computer.dart @@ -23,10 +23,9 @@ class _ComputerBase extends BodyComponent with InitialPosition { _ComputerBase() : super( priority: RenderPriority.computerBase, + renderBody: false, children: [_ComputerBaseSpriteComponent()], - ) { - renderBody = false; - } + ); List _createFixtureDefs() { final leftEdge = EdgeShape() diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 055d8e99..c260b626 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -7,8 +7,13 @@ environment: sdk: ">=2.16.0 <3.0.0" dependencies: + bloc: ^8.0.3 flame: ^1.1.1 - flame_forge2d: ^0.11.0 + flame_forge2d: + git: + url: https://github.com/flame-engine/flame/ + path: packages/flame_forge2d/ + ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: sdk: flutter geometry: @@ -19,8 +24,8 @@ dependencies: pinball_theme: path: ../pinball_theme - dev_dependencies: + bloc_test: ^9.0.3 flame_test: ^1.3.0 flutter_test: sdk: flutter @@ -59,8 +64,8 @@ flutter: - assets/images/kicker/ - assets/images/plunger/ - assets/images/slingshot/ - - assets/images/alien_bumper/a/ - - assets/images/alien_bumper/b/ + - assets/images/android_bumper/a/ + - assets/images/android_bumper/b/ - assets/images/sparky/ - assets/images/sparky/computer/ - assets/images/sparky/bumper/a/ diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index e5f7f177..40396b88 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -23,7 +23,7 @@ void main() { addPlungerStories(dashbook); addSlingshotStories(dashbook); addSparkyBumperStories(dashbook); - addAlienZoneStories(dashbook); + addAndroidAcresStories(dashbook); addBoundariesStories(dashbook); addGoogleWordStories(dashbook); addLaunchRampStories(dashbook); diff --git a/packages/pinball_components/sandbox/lib/stories/alien_zone/stories.dart b/packages/pinball_components/sandbox/lib/stories/alien_zone/stories.dart deleted file mode 100644 index b4e7c1b6..00000000 --- a/packages/pinball_components/sandbox/lib/stories/alien_zone/stories.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:dashbook/dashbook.dart'; -import 'package:sandbox/common/common.dart'; -import 'package:sandbox/stories/alien_zone/alien_bumper_a_game.dart'; -import 'package:sandbox/stories/alien_zone/alien_bumper_b_game.dart'; -import 'package:sandbox/stories/alien_zone/spaceship_game.dart'; -import 'package:sandbox/stories/alien_zone/spaceship_rail_game.dart'; -import 'package:sandbox/stories/alien_zone/spaceship_ramp_game.dart'; - -void addAlienZoneStories(Dashbook dashbook) { - dashbook.storiesOf('Alien Zone') - ..addGame( - title: 'Alien Bumper A', - description: AlienBumperAGame.description, - gameBuilder: (_) => AlienBumperAGame(), - ) - ..addGame( - title: 'Alien Bumper B', - description: AlienBumperBGame.description, - gameBuilder: (_) => AlienBumperBGame(), - ) - ..addGame( - title: 'Spaceship', - description: SpaceshipGame.description, - gameBuilder: (_) => SpaceshipGame(), - ) - ..addGame( - title: 'Spaceship Rail', - description: SpaceshipRailGame.description, - gameBuilder: (_) => SpaceshipRailGame(), - ) - ..addGame( - title: 'Spaceship Ramp', - description: SpaceshipRampGame.description, - gameBuilder: (_) => SpaceshipRampGame(), - ); -} diff --git a/packages/pinball_components/sandbox/lib/stories/alien_zone/alien_bumper_b_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_a_game.dart similarity index 68% rename from packages/pinball_components/sandbox/lib/stories/alien_zone/alien_bumper_b_game.dart rename to packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_a_game.dart index abb206ca..4dcd1cb8 100644 --- a/packages/pinball_components/sandbox/lib/stories/alien_zone/alien_bumper_b_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_a_game.dart @@ -4,18 +4,18 @@ import 'package:flame/extensions.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; -class AlienBumperBGame extends BallGame { - AlienBumperBGame() +class AndroidBumperAGame extends BallGame { + AndroidBumperAGame() : super( color: const Color(0xFF0000FF), imagesFileNames: [ - Assets.images.alienBumper.b.active.keyName, - Assets.images.alienBumper.b.inactive.keyName, + Assets.images.androidBumper.a.lit.keyName, + Assets.images.androidBumper.a.dimmed.keyName, ], ); static const description = ''' - Shows how a AlienBumperB is rendered. + Shows how a AndroidBumperA is rendered. - Activate the "trace" parameter to overlay the body. '''; @@ -26,7 +26,7 @@ class AlienBumperBGame extends BallGame { camera.followVector2(Vector2.zero()); await add( - AlienBumper.b()..priority = 1, + AndroidBumper.a()..priority = 1, ); await traceAllBodies(); diff --git a/packages/pinball_components/sandbox/lib/stories/alien_zone/alien_bumper_a_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_b_game.dart similarity index 68% rename from packages/pinball_components/sandbox/lib/stories/alien_zone/alien_bumper_a_game.dart rename to packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_b_game.dart index 4832a468..e504fe1e 100644 --- a/packages/pinball_components/sandbox/lib/stories/alien_zone/alien_bumper_a_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_b_game.dart @@ -4,18 +4,18 @@ import 'package:flame/extensions.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; -class AlienBumperAGame extends BallGame { - AlienBumperAGame() +class AndroidBumperBGame extends BallGame { + AndroidBumperBGame() : super( color: const Color(0xFF0000FF), imagesFileNames: [ - Assets.images.alienBumper.a.active.keyName, - Assets.images.alienBumper.a.inactive.keyName, + Assets.images.androidBumper.b.lit.keyName, + Assets.images.androidBumper.b.dimmed.keyName, ], ); static const description = ''' - Shows how a AlienBumperA is rendered. + Shows how a AndroidBumperB is rendered. - Activate the "trace" parameter to overlay the body. '''; @@ -26,7 +26,7 @@ class AlienBumperAGame extends BallGame { camera.followVector2(Vector2.zero()); await add( - AlienBumper.a()..priority = 1, + AndroidBumper.b()..priority = 1, ); await traceAllBodies(); diff --git a/packages/pinball_components/sandbox/lib/stories/alien_zone/spaceship_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_game.dart similarity index 100% rename from packages/pinball_components/sandbox/lib/stories/alien_zone/spaceship_game.dart rename to packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_game.dart diff --git a/packages/pinball_components/sandbox/lib/stories/alien_zone/spaceship_rail_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_rail_game.dart similarity index 85% rename from packages/pinball_components/sandbox/lib/stories/alien_zone/spaceship_rail_game.dart rename to packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_rail_game.dart index 2a13fb5e..4bd067fa 100644 --- a/packages/pinball_components/sandbox/lib/stories/alien_zone/spaceship_rail_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_rail_game.dart @@ -12,6 +12,10 @@ class SpaceshipRailGame extends BallGame { color: Colors.blue, ballPriority: RenderPriority.ballOnSpaceshipRail, ballLayer: Layer.spaceshipExitRail, + imagesFileNames: [ + Assets.images.spaceship.rail.main.keyName, + Assets.images.spaceship.rail.exit.keyName, + ], ); static const description = ''' diff --git a/packages/pinball_components/sandbox/lib/stories/alien_zone/spaceship_ramp_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart similarity index 100% rename from packages/pinball_components/sandbox/lib/stories/alien_zone/spaceship_ramp_game.dart rename to packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart diff --git a/packages/pinball_components/sandbox/lib/stories/android_acres/stories.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/stories.dart new file mode 100644 index 00000000..92ddd5d5 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/stories.dart @@ -0,0 +1,36 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/android_acres/android_bumper_a_game.dart'; +import 'package:sandbox/stories/android_acres/android_bumper_b_game.dart'; +import 'package:sandbox/stories/android_acres/spaceship_game.dart'; +import 'package:sandbox/stories/android_acres/spaceship_rail_game.dart'; +import 'package:sandbox/stories/android_acres/spaceship_ramp_game.dart'; + +void addAndroidAcresStories(Dashbook dashbook) { + dashbook.storiesOf('Android Acres') + ..addGame( + title: 'Android Bumper A', + description: AndroidBumperAGame.description, + gameBuilder: (_) => AndroidBumperAGame(), + ) + ..addGame( + title: 'Android Bumper B', + description: AndroidBumperBGame.description, + gameBuilder: (_) => AndroidBumperBGame(), + ) + ..addGame( + title: 'Spaceship', + description: SpaceshipGame.description, + gameBuilder: (_) => SpaceshipGame(), + ) + ..addGame( + title: 'Spaceship Rail', + description: SpaceshipRailGame.description, + gameBuilder: (_) => SpaceshipRailGame(), + ) + ..addGame( + title: 'Spaceship Ramp', + description: SpaceshipRampGame.description, + gameBuilder: (_) => SpaceshipRampGame(), + ); +} diff --git a/packages/pinball_components/sandbox/lib/stories/google_word/google_letter_game.dart b/packages/pinball_components/sandbox/lib/stories/google_word/google_letter_game.dart index d8022e57..be90fdb9 100644 --- a/packages/pinball_components/sandbox/lib/stories/google_word/google_letter_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/google_word/google_letter_game.dart @@ -17,7 +17,6 @@ class GoogleLetterGame extends BallGame { @override Future onLoad() async { await super.onLoad(); - addContactCallback(_BallGoogleLetterContactCallback()); camera.followVector2(Vector2.zero()); await add(GoogleLetter(0)); @@ -25,12 +24,3 @@ class GoogleLetterGame extends BallGame { await traceAllBodies(); } } - -class _BallGoogleLetterContactCallback - extends ContactCallback { - @override - void begin(Ball a, GoogleLetter b, Contact contact) { - super.begin(a, b, contact); - b.activate(); - } -} diff --git a/packages/pinball_components/sandbox/lib/stories/stories.dart b/packages/pinball_components/sandbox/lib/stories/stories.dart index d8103b4d..df51fc0f 100644 --- a/packages/pinball_components/sandbox/lib/stories/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/stories.dart @@ -1,4 +1,4 @@ -export 'alien_zone/stories.dart'; +export 'android_acres/stories.dart'; export 'backboard/stories.dart'; export 'ball/stories.dart'; export 'baseboard/stories.dart'; diff --git a/packages/pinball_components/sandbox/pubspec.lock b/packages/pinball_components/sandbox/pubspec.lock index 5bf0eb12..8d61da32 100644 --- a/packages/pinball_components/sandbox/pubspec.lock +++ b/packages/pinball_components/sandbox/pubspec.lock @@ -102,9 +102,11 @@ packages: flame_forge2d: dependency: "direct main" description: - name: flame_forge2d - url: "https://pub.dartlang.org" - source: hosted + path: "packages/flame_forge2d" + ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f + resolved-ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f + url: "https://github.com/flame-engine/flame/" + source: git version: "0.11.0" flutter: dependency: "direct main" diff --git a/packages/pinball_components/sandbox/pubspec.yaml b/packages/pinball_components/sandbox/pubspec.yaml index dd9f8259..d663cb04 100644 --- a/packages/pinball_components/sandbox/pubspec.yaml +++ b/packages/pinball_components/sandbox/pubspec.yaml @@ -9,7 +9,11 @@ environment: dependencies: dashbook: ^0.1.7 flame: ^1.1.1 - flame_forge2d: ^0.11.0 + flame_forge2d: + git: + url: https://github.com/flame-engine/flame/ + path: packages/flame_forge2d/ + ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: sdk: flutter pinball_components: diff --git a/packages/pinball_components/test/helpers/mocks.dart b/packages/pinball_components/test/helpers/mocks.dart index 520555df..2230becb 100644 --- a/packages/pinball_components/test/helpers/mocks.dart +++ b/packages/pinball_components/test/helpers/mocks.dart @@ -15,7 +15,12 @@ class MockGame extends Mock implements Forge2DGame {} class MockContact extends Mock implements Contact {} -class MockContactCallback extends Mock - implements ContactCallback {} - class MockComponent extends Mock implements Component {} + +class MockAndroidBumperCubit extends Mock implements AndroidBumperCubit {} + +class MockGoogleLetterCubit extends Mock implements GoogleLetterCubit {} + +class MockSparkyBumperCubit extends Mock implements SparkyBumperCubit {} + +class MockDashNestBumperCubit extends Mock implements DashNestBumperCubit {} diff --git a/packages/pinball_components/test/src/components/alien_bumper_test.dart b/packages/pinball_components/test/src/components/alien_bumper_test.dart deleted file mode 100644 index c6384759..00000000 --- a/packages/pinball_components/test/src/components/alien_bumper_test.dart +++ /dev/null @@ -1,61 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame/components.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 assets = [ - Assets.images.alienBumper.a.active.keyName, - Assets.images.alienBumper.a.inactive.keyName, - Assets.images.alienBumper.b.active.keyName, - Assets.images.alienBumper.b.inactive.keyName, - ]; - final flameTester = FlameTester(() => TestGame(assets)); - - group('AlienBumper', () { - flameTester.test('"a" loads correctly', (game) async { - final bumper = AlienBumper.a(); - await game.ensureAdd(bumper); - - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('"b" loads correctly', (game) async { - final bumper = AlienBumper.b(); - await game.ensureAdd(bumper); - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('animate switches between on and off sprites', - (game) async { - final bumper = AlienBumper.a(); - await game.ensureAdd(bumper); - - final spriteGroupComponent = bumper.firstChild()!; - - expect( - spriteGroupComponent.current, - equals(AlienBumperSpriteState.active), - ); - - final future = bumper.animate(); - - expect( - spriteGroupComponent.current, - equals(AlienBumperSpriteState.inactive), - ); - - await future; - - expect( - spriteGroupComponent.current, - equals(AlienBumperSpriteState.active), - ); - }); - }); -} diff --git a/packages/pinball_components/test/src/components/android_bumper/android_bumper_test.dart b/packages/pinball_components/test/src/components/android_bumper/android_bumper_test.dart new file mode 100644 index 00000000..abc51a28 --- /dev/null +++ b/packages/pinball_components/test/src/components/android_bumper/android_bumper_test.dart @@ -0,0 +1,78 @@ +// 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/android_bumper/behaviors/behaviors.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.androidBumper.a.lit.keyName, + Assets.images.androidBumper.a.dimmed.keyName, + Assets.images.androidBumper.b.lit.keyName, + Assets.images.androidBumper.b.dimmed.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + group('AndroidBumper', () { + flameTester.test('"a" loads correctly', (game) async { + final androidBumper = AndroidBumper.a(); + await game.ensureAdd(androidBumper); + expect(game.contains(androidBumper), isTrue); + }); + + flameTester.test('"b" loads correctly', (game) async { + final androidBumper = AndroidBumper.b(); + await game.ensureAdd(androidBumper); + expect(game.contains(androidBumper), isTrue); + }); + + // 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 = MockAndroidBumperCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: AndroidBumperState.lit, + ); + when(bloc.close).thenAnswer((_) async {}); + final androidBumper = AndroidBumper.test(bloc: bloc); + + await game.ensureAdd(androidBumper); + game.remove(androidBumper); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final androidBumper = AndroidBumper.a( + children: [component], + ); + await game.ensureAdd(androidBumper); + expect(androidBumper.children, contains(component)); + }); + + flameTester.test('an AndroidBumperBallContactBehavior', (game) async { + final androidBumper = AndroidBumper.a(); + await game.ensureAdd(androidBumper); + expect( + androidBumper.children + .whereType() + .single, + isNotNull, + ); + }); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/android_bumper/behaviors/android_bumper_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/android_bumper/behaviors/android_bumper_ball_contact_behavior_test.dart new file mode 100644 index 00000000..69e6ce43 --- /dev/null +++ b/packages/pinball_components/test/src/components/android_bumper/behaviors/android_bumper_ball_contact_behavior_test.dart @@ -0,0 +1,48 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.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/android_bumper/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'AndroidBumperBallContactBehavior', + () { + test('can be instantiated', () { + expect( + AndroidBumperBallContactBehavior(), + isA(), + ); + }); + + flameTester.test( + 'beginContact emits onBallContacted when contacts with a ball', + (game) async { + final behavior = AndroidBumperBallContactBehavior(); + final bloc = MockAndroidBumperCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: AndroidBumperState.lit, + ); + + final androidBumper = AndroidBumper.test(bloc: bloc); + await androidBumper.add(behavior); + await game.ensureAdd(androidBumper); + + behavior.beginContact(MockBall(), MockContact()); + + verify(androidBumper.bloc.onBallContacted).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/android_bumper/behaviors/android_bumper_blinking_behavior_test.dart b/packages/pinball_components/test/src/components/android_bumper/behaviors/android_bumper_blinking_behavior_test.dart new file mode 100644 index 00000000..f7b09dfb --- /dev/null +++ b/packages/pinball_components/test/src/components/android_bumper/behaviors/android_bumper_blinking_behavior_test.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.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/android_bumper/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'AndroidBumperBlinkingBehavior', + () { + flameTester.testGameWidget( + 'calls onBlinked after 0.05 seconds when dimmed', + setUp: (game, tester) async { + final behavior = AndroidBumperBlinkingBehavior(); + final bloc = MockAndroidBumperCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: AndroidBumperState.lit, + ); + + final androidBumper = AndroidBumper.test(bloc: bloc); + await androidBumper.add(behavior); + await game.ensureAdd(androidBumper); + + streamController.add(AndroidBumperState.dimmed); + await tester.pump(); + game.update(0.05); + + await streamController.close(); + verify(bloc.onBlinked).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/android_bumper/cubit/android_bumper_cubit_test.dart b/packages/pinball_components/test/src/components/android_bumper/cubit/android_bumper_cubit_test.dart new file mode 100644 index 00000000..06095228 --- /dev/null +++ b/packages/pinball_components/test/src/components/android_bumper/cubit/android_bumper_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( + 'AndroidBumperCubit', + () { + blocTest( + 'onBallContacted emits dimmed', + build: AndroidBumperCubit.new, + act: (bloc) => bloc.onBallContacted(), + expect: () => [AndroidBumperState.dimmed], + ); + + blocTest( + 'onBlinked emits lit', + build: AndroidBumperCubit.new, + act: (bloc) => bloc.onBlinked(), + expect: () => [AndroidBumperState.lit], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/bumping_behavior_test.dart b/packages/pinball_components/test/src/components/bumping_behavior_test.dart new file mode 100644 index 00000000..d346a0ae --- /dev/null +++ b/packages/pinball_components/test/src/components/bumping_behavior_test.dart @@ -0,0 +1,84 @@ +// 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_components/src/components/bumping_behavior.dart'; + +import '../../helpers/helpers.dart'; +import 'layer_test.dart'; + +class MockContactImpulse extends Mock implements ContactImpulse {} + +class MockManifold extends Mock implements Manifold {} + +class TestHeavyBodyComponent extends BodyComponent { + @override + Body createBody() { + final shape = CircleShape(); + return world.createBody( + BodyDef( + type: BodyType.dynamic, + ), + )..createFixtureFromShape(shape, 20); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group('BumpingBehavior', () { + flameTester.test('can be added', (game) async { + final behavior = BumpingBehavior(strength: 0); + final component = TestBodyComponent(); + await component.add(behavior); + await game.ensureAdd(component); + }); + + flameTester.testGameWidget( + 'the bump is greater when the strengh is greater', + setUp: (game, tester) async { + final component1 = TestBodyComponent(); + final behavior1 = BumpingBehavior(strength: 1); + await component1.add(behavior1); + + final component2 = TestBodyComponent(); + final behavior2 = BumpingBehavior(strength: 2); + await component2.add(behavior2); + + final dummy1 = TestHeavyBodyComponent(); + final dummy2 = TestHeavyBodyComponent(); + + await game.ensureAddAll([ + component1, + component2, + dummy1, + dummy2, + ]); + + expect(dummy1.body.inverseMass, greaterThan(0)); + expect(dummy2.body.inverseMass, greaterThan(0)); + + final contact = MockContact(); + final manifold = MockManifold(); + final contactImpulse = MockContactImpulse(); + when(() => manifold.localPoint).thenReturn(Vector2.all(1)); + when(() => contact.manifold).thenReturn(manifold); + + behavior1.postSolve(dummy1, contact, contactImpulse); + behavior2.postSolve(dummy2, contact, contactImpulse); + + expect( + dummy2.body.linearVelocity.x, + greaterThan(dummy1.body.linearVelocity.x), + ); + expect( + dummy2.body.linearVelocity.y, + greaterThan(dummy1.body.linearVelocity.y), + ); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/chrome_dino_test.dart b/packages/pinball_components/test/src/components/chrome_dino_test.dart index 16e02647..f97270b9 100644 --- a/packages/pinball_components/test/src/components/chrome_dino_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino_test.dart @@ -65,14 +65,12 @@ void main() { ); group('swivels', () { - flameTester.testGameWidget( + flameTester.test( 'up', - setUp: (game, tester) async { - await game.images.loadAll(assets); + (game) async { final chromeDino = ChromeDino(); await game.ensureAdd(chromeDino); game.camera.followVector2(Vector2.zero()); - await tester.pump(); final sweepAnimationDuration = game .descendants() @@ -82,20 +80,17 @@ void main() { .totalDuration() / 2; game.update(sweepAnimationDuration * 1.5); - await tester.pump(); expect(chromeDino.body.angularVelocity, isPositive); }, ); - flameTester.testGameWidget( + flameTester.test( 'down', - setUp: (game, tester) async { - await game.images.loadAll(assets); + (game) async { final chromeDino = ChromeDino(); await game.ensureAdd(chromeDino); game.camera.followVector2(Vector2.zero()); - await tester.pump(); final sweepAnimationDuration = game .descendants() @@ -105,7 +100,6 @@ void main() { .totalDuration() / 2; game.update(sweepAnimationDuration * 0.5); - await tester.pump(); expect(chromeDino.body.angularVelocity, isNegative); }, diff --git a/packages/pinball_components/test/src/components/dash_nest_bumper/behaviors/dash_nest_bumper_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/dash_nest_bumper/behaviors/dash_nest_bumper_ball_contact_behavior_test.dart new file mode 100644 index 00000000..bf7513bd --- /dev/null +++ b/packages/pinball_components/test/src/components/dash_nest_bumper/behaviors/dash_nest_bumper_ball_contact_behavior_test.dart @@ -0,0 +1,48 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.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/dash_nest_bumper/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'DashNestBumperBallContactBehavior', + () { + test('can be instantiated', () { + expect( + DashNestBumperBallContactBehavior(), + isA(), + ); + }); + + flameTester.test( + 'beginContact emits onBallContacted when contacts with a ball', + (game) async { + final behavior = DashNestBumperBallContactBehavior(); + final bloc = MockDashNestBumperCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: DashNestBumperState.active, + ); + + final dashNestBumper = DashNestBumper.test(bloc: bloc); + await dashNestBumper.add(behavior); + await game.ensureAdd(dashNestBumper); + + behavior.beginContact(MockBall(), MockContact()); + + verify(dashNestBumper.bloc.onBallContacted).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/dash_nest_bumper/cubit/dash_nest_bumper_cubit_test.dart b/packages/pinball_components/test/src/components/dash_nest_bumper/cubit/dash_nest_bumper_cubit_test.dart new file mode 100644 index 00000000..7e26bbf3 --- /dev/null +++ b/packages/pinball_components/test/src/components/dash_nest_bumper/cubit/dash_nest_bumper_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( + 'DashNestBumperCubit', + () { + blocTest( + 'onBallContacted emits active', + build: DashNestBumperCubit.new, + act: (bloc) => bloc.onBallContacted(), + expect: () => [DashNestBumperState.active], + ); + + blocTest( + 'onReset emits inactive', + build: DashNestBumperCubit.new, + act: (bloc) => bloc.onReset(), + expect: () => [DashNestBumperState.inactive], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/dash_nest_bumper/dash_nest_bumper_test.dart b/packages/pinball_components/test/src/components/dash_nest_bumper/dash_nest_bumper_test.dart new file mode 100644 index 00000000..67764951 --- /dev/null +++ b/packages/pinball_components/test/src/components/dash_nest_bumper/dash_nest_bumper_test.dart @@ -0,0 +1,88 @@ +// 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/dash_nest_bumper/behaviors/behaviors.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('DashNestBumper', () { + final assets = [ + Assets.images.dash.bumper.main.active.keyName, + Assets.images.dash.bumper.main.inactive.keyName, + Assets.images.dash.bumper.a.active.keyName, + Assets.images.dash.bumper.a.inactive.keyName, + Assets.images.dash.bumper.b.active.keyName, + Assets.images.dash.bumper.b.inactive.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + flameTester.test('"main" loads correctly', (game) async { + final bumper = DashNestBumper.main(); + await game.ensureAdd(bumper); + expect(game.contains(bumper), isTrue); + }); + + flameTester.test('"a" loads correctly', (game) async { + final bumper = DashNestBumper.a(); + await game.ensureAdd(bumper); + + expect(game.contains(bumper), isTrue); + }); + + flameTester.test('"b" loads correctly', (game) async { + final bumper = DashNestBumper.b(); + await game.ensureAdd(bumper); + expect(game.contains(bumper), isTrue); + }); + + // 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 = MockDashNestBumperCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: DashNestBumperState.inactive, + ); + when(bloc.close).thenAnswer((_) async {}); + final dashNestBumper = DashNestBumper.test(bloc: bloc); + + await game.ensureAdd(dashNestBumper); + game.remove(dashNestBumper); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('adds new children', (game) async { + final component = Component(); + final dashNestBumper = DashNestBumper.a( + children: [component], + ); + await game.ensureAdd(dashNestBumper); + expect(dashNestBumper.children, contains(component)); + }); + + flameTester.test('a DashNestBumperBallContactBehavior', (game) async { + final dashNestBumper = DashNestBumper.a(); + await game.ensureAdd(dashNestBumper); + expect( + dashNestBumper.children + .whereType() + .single, + isNotNull, + ); + }); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/dash_nest_bumper_test.dart b/packages/pinball_components/test/src/components/dash_nest_bumper_test.dart deleted file mode 100644 index ac036ef4..00000000 --- a/packages/pinball_components/test/src/components/dash_nest_bumper_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame/components.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(); - - group('DashNestBumper', () { - final assets = [ - Assets.images.dash.bumper.main.active.keyName, - Assets.images.dash.bumper.main.inactive.keyName, - Assets.images.dash.bumper.a.active.keyName, - Assets.images.dash.bumper.a.inactive.keyName, - Assets.images.dash.bumper.b.active.keyName, - Assets.images.dash.bumper.b.inactive.keyName, - ]; - final flameTester = FlameTester(() => TestGame(assets)); - - flameTester.test('"main" loads correctly', (game) async { - final bumper = DashNestBumper.main(); - await game.ensureAdd(bumper); - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('"a" loads correctly', (game) async { - final bumper = DashNestBumper.a(); - await game.ensureAdd(bumper); - - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('"b" loads correctly', (game) async { - final bumper = DashNestBumper.b(); - await game.ensureAdd(bumper); - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('activate switches to active sprite', (game) async { - final bumper = DashNestBumper.main(); - await game.ensureAdd(bumper); - - final spriteGroupComponent = bumper.firstChild()!; - - expect( - spriteGroupComponent.current, - equals(DashNestBumperSpriteState.inactive), - ); - - bumper.activate(); - - expect( - spriteGroupComponent.current, - equals(DashNestBumperSpriteState.active), - ); - }); - - flameTester.test('deactivate switches to inactive sprite', (game) async { - final bumper = DashNestBumper.main(); - await game.ensureAdd(bumper); - - final spriteGroupComponent = bumper.firstChild()! - ..current = DashNestBumperSpriteState.active; - - bumper.deactivate(); - - expect( - spriteGroupComponent.current, - equals(DashNestBumperSpriteState.inactive), - ); - }); - }); -} diff --git a/packages/pinball_components/test/src/components/golden/spaceship-rail.png b/packages/pinball_components/test/src/components/golden/spaceship-rail.png index d81f7dba..d8ce5fca 100644 Binary files a/packages/pinball_components/test/src/components/golden/spaceship-rail.png and b/packages/pinball_components/test/src/components/golden/spaceship-rail.png differ diff --git a/packages/pinball_components/test/src/components/google_letter/behaviors/google_letter_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/google_letter/behaviors/google_letter_ball_contact_behavior_test.dart new file mode 100644 index 00000000..bf261460 --- /dev/null +++ b/packages/pinball_components/test/src/components/google_letter/behaviors/google_letter_ball_contact_behavior_test.dart @@ -0,0 +1,48 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.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/google_letter/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'GoogleLetterBallContactBehavior', + () { + test('can be instantiated', () { + expect( + GoogleLetterBallContactBehavior(), + isA(), + ); + }); + + flameTester.test( + 'beginContact emits onBallContacted when contacts with a ball', + (game) async { + final behavior = GoogleLetterBallContactBehavior(); + final bloc = MockGoogleLetterCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: GoogleLetterState.active, + ); + + final googleLetter = GoogleLetter.test(bloc: bloc); + await googleLetter.add(behavior); + await game.ensureAdd(googleLetter); + + behavior.beginContact(MockBall(), MockContact()); + + verify(googleLetter.bloc.onBallContacted).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/google_letter/cubit/google_letter_cubit_test.dart b/packages/pinball_components/test/src/components/google_letter/cubit/google_letter_cubit_test.dart new file mode 100644 index 00000000..390aa192 --- /dev/null +++ b/packages/pinball_components/test/src/components/google_letter/cubit/google_letter_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( + 'GoogleLetterCubit', + () { + blocTest( + 'onBallContacted emits active', + build: GoogleLetterCubit.new, + act: (bloc) => bloc.onBallContacted(), + expect: () => [GoogleLetterState.active], + ); + + blocTest( + 'onReset emits inactive', + build: GoogleLetterCubit.new, + act: (bloc) => bloc.onReset(), + expect: () => [GoogleLetterState.inactive], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/google_letter_test.dart b/packages/pinball_components/test/src/components/google_letter/google_letter_test.dart similarity index 62% rename from packages/pinball_components/test/src/components/google_letter_test.dart rename to packages/pinball_components/test/src/components/google_letter/google_letter_test.dart index cdfd3c4a..624168b9 100644 --- a/packages/pinball_components/test/src/components/google_letter_test.dart +++ b/packages/pinball_components/test/src/components/google_letter/google_letter_test.dart @@ -1,11 +1,13 @@ // ignore_for_file: cascade_invocations -import 'package:flame/effects.dart'; +import 'package:bloc_test/bloc_test.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/google_letter/behaviors/behaviors.dart'; -import '../../helpers/helpers.dart'; +import '../../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -83,44 +85,35 @@ void main() { expect(() => GoogleLetter(6), throwsA(isA())); }); - group('activate', () { - flameTester.test('returns normally', (game) async { - final googleLetter = GoogleLetter(0); - await game.ensureAdd(googleLetter); - await expectLater(googleLetter.activate, returnsNormally); - }); - - flameTester.test('adds an Effect', (game) async { - final googleLetter = GoogleLetter(0); - await game.ensureAdd(googleLetter); - await googleLetter.activate(); - await game.ready(); - - expect( - googleLetter.descendants().whereType().length, - equals(1), - ); - }); + // 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 = MockGoogleLetterCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: GoogleLetterState.active, + ); + when(bloc.close).thenAnswer((_) async {}); + final googleLetter = GoogleLetter.test(bloc: bloc); + + await game.ensureAdd(googleLetter); + game.remove(googleLetter); + await game.ready(); + + verify(bloc.close).called(1); }); - group('deactivate', () { - flameTester.test('returns normally', (game) async { - final googleLetter = GoogleLetter(0); - await game.ensureAdd(googleLetter); - await expectLater(googleLetter.deactivate, returnsNormally); - }); - - flameTester.test('adds an Effect', (game) async { - final googleLetter = GoogleLetter(0); - await game.ensureAdd(googleLetter); - await googleLetter.deactivate(); - await game.ready(); - - expect( - googleLetter.descendants().whereType().length, - equals(1), - ); - }); + flameTester.test('adds a GoogleLetterBallContactBehavior', (game) async { + final googleLetter = GoogleLetter(0); + await game.ensureAdd(googleLetter); + expect( + googleLetter.children + .whereType() + .single, + isNotNull, + ); }); }); } diff --git a/packages/pinball_components/test/src/components/layer_sensor_test.dart b/packages/pinball_components/test/src/components/layer_sensor_test.dart index f91a6bcb..2d1b21be 100644 --- a/packages/pinball_components/test/src/components/layer_sensor_test.dart +++ b/packages/pinball_components/test/src/components/layer_sensor_test.dart @@ -22,11 +22,6 @@ class TestLayerSensor extends LayerSensor { Shape get shape => PolygonShape()..setAsBoxXY(1, 1); } -class TestLayerSensorBallContactCallback - extends LayerSensorBallContactCallback { - TestLayerSensorBallContactCallback() : super(); -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); final flameTester = FlameTester(TestGame.new); @@ -113,7 +108,7 @@ void main() { }); }); - group('LayerSensorBallContactCallback', () { + group('beginContact', () { late Ball ball; late Body body; @@ -135,18 +130,17 @@ void main() { insidePriority: insidePriority, insideLayer: Layer.spaceshipEntranceRamp, )..initialPosition = Vector2(0, 10); - final callback = TestLayerSensorBallContactCallback(); when(() => body.linearVelocity).thenReturn(Vector2(0, -1)); - callback.begin(ball, sensor, MockContact()); + sensor.beginContact(ball, MockContact()); verify(() => ball.layer = sensor.insideLayer).called(1); verify(() => ball.priority = sensor.insidePriority).called(1); verify(ball.reorderChildren).called(1); when(() => ball.layer).thenReturn(sensor.insideLayer); - callback.begin(ball, sensor, MockContact()); + sensor.beginContact(ball, MockContact()); verify(() => ball.layer = Layer.board); verify(() => ball.priority = RenderPriority.ballOnBoard).called(1); verify(ball.reorderChildren).called(1); @@ -161,18 +155,17 @@ void main() { insidePriority: insidePriority, insideLayer: Layer.spaceshipEntranceRamp, )..initialPosition = Vector2(0, 10); - final callback = TestLayerSensorBallContactCallback(); when(() => body.linearVelocity).thenReturn(Vector2(0, 1)); - callback.begin(ball, sensor, MockContact()); + sensor.beginContact(ball, MockContact()); verify(() => ball.layer = sensor.insideLayer).called(1); verify(() => ball.priority = sensor.insidePriority).called(1); verify(ball.reorderChildren).called(1); when(() => ball.layer).thenReturn(sensor.insideLayer); - callback.begin(ball, sensor, MockContact()); + sensor.beginContact(ball, MockContact()); verify(() => ball.layer = Layer.board); verify(() => ball.priority = RenderPriority.ballOnBoard).called(1); verify(ball.reorderChildren).called(1); diff --git a/packages/pinball_components/test/src/components/signpost_test.dart b/packages/pinball_components/test/src/components/signpost_test.dart index 018c1bee..23aa6bd0 100644 --- a/packages/pinball_components/test/src/components/signpost_test.dart +++ b/packages/pinball_components/test/src/components/signpost_test.dart @@ -151,5 +151,14 @@ void main() { expect(spriteComponent.current, SignpostSpriteState.inactive); }, ); + + flameTester.test('adds new children', (game) async { + final component = Component(); + final signpost = Signpost( + children: [component], + ); + await game.ensureAdd(signpost); + expect(signpost.children, contains(component)); + }); }); } diff --git a/packages/pinball_components/test/src/components/spaceship_rail_test.dart b/packages/pinball_components/test/src/components/spaceship_rail_test.dart index d3242ff6..bc5a7f75 100644 --- a/packages/pinball_components/test/src/components/spaceship_rail_test.dart +++ b/packages/pinball_components/test/src/components/spaceship_rail_test.dart @@ -11,13 +11,19 @@ import '../../helpers/helpers.dart'; void main() { group('SpaceshipRail', () { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(TestGame.new); + final assets = [ + Assets.images.spaceship.rail.main.keyName, + Assets.images.spaceship.rail.exit.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); flameTester.testGameWidget( 'renders correctly', setUp: (game, tester) async { + await game.images.loadAll(assets); await game.addFromBlueprint(SpaceshipRail()); await game.ready(); + await tester.pump(); game.camera.followVector2(Vector2.zero()); game.camera.zoom = 8; diff --git a/packages/pinball_components/test/src/components/sparky_bumper/behaviors/sparky_bumper_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/sparky_bumper/behaviors/sparky_bumper_ball_contact_behavior_test.dart new file mode 100644 index 00000000..88bd8145 --- /dev/null +++ b/packages/pinball_components/test/src/components/sparky_bumper/behaviors/sparky_bumper_ball_contact_behavior_test.dart @@ -0,0 +1,48 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.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_bumper/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'SparkyBumperBallContactBehavior', + () { + test('can be instantiated', () { + expect( + SparkyBumperBallContactBehavior(), + isA(), + ); + }); + + flameTester.test( + 'beginContact emits onBallContacted when contacts with a ball', + (game) async { + final behavior = SparkyBumperBallContactBehavior(); + final bloc = MockSparkyBumperCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SparkyBumperState.active, + ); + + final sparkyBumper = SparkyBumper.test(bloc: bloc); + await sparkyBumper.add(behavior); + await game.ensureAdd(sparkyBumper); + + behavior.beginContact(MockBall(), MockContact()); + + verify(sparkyBumper.bloc.onBallContacted).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/sparky_bumper/behaviors/sparky_bumper_blinking_behavior_test.dart b/packages/pinball_components/test/src/components/sparky_bumper/behaviors/sparky_bumper_blinking_behavior_test.dart new file mode 100644 index 00000000..0d938820 --- /dev/null +++ b/packages/pinball_components/test/src/components/sparky_bumper/behaviors/sparky_bumper_blinking_behavior_test.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.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_bumper/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'SparkyBumperBlinkingBehavior', + () { + flameTester.testGameWidget( + 'calls onBlinked after 0.05 seconds when inactive', + setUp: (game, tester) async { + final behavior = SparkyBumperBlinkingBehavior(); + final bloc = MockSparkyBumperCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SparkyBumperState.active, + ); + + final sparkyBumper = SparkyBumper.test(bloc: bloc); + await sparkyBumper.add(behavior); + await game.ensureAdd(sparkyBumper); + + streamController.add(SparkyBumperState.inactive); + await tester.pump(); + game.update(0.05); + + await streamController.close(); + verify(bloc.onBlinked).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/sparky_bumper/cubit/sparky_bumper_cubit_test.dart b/packages/pinball_components/test/src/components/sparky_bumper/cubit/sparky_bumper_cubit_test.dart new file mode 100644 index 00000000..4192f806 --- /dev/null +++ b/packages/pinball_components/test/src/components/sparky_bumper/cubit/sparky_bumper_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( + 'SparkyBumperCubit', + () { + blocTest( + 'onBallContacted emits inactive', + build: SparkyBumperCubit.new, + act: (bloc) => bloc.onBallContacted(), + expect: () => [SparkyBumperState.inactive], + ); + + blocTest( + 'onBlinked emits active', + build: SparkyBumperCubit.new, + act: (bloc) => bloc.onBlinked(), + expect: () => [SparkyBumperState.active], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/sparky_bumper/sparky_bumper_test.dart b/packages/pinball_components/test/src/components/sparky_bumper/sparky_bumper_test.dart new file mode 100644 index 00000000..225b5922 --- /dev/null +++ b/packages/pinball_components/test/src/components/sparky_bumper/sparky_bumper_test.dart @@ -0,0 +1,86 @@ +// 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_bumper/behaviors/behaviors.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.sparky.bumper.a.active.keyName, + Assets.images.sparky.bumper.a.inactive.keyName, + Assets.images.sparky.bumper.b.active.keyName, + Assets.images.sparky.bumper.b.inactive.keyName, + Assets.images.sparky.bumper.c.active.keyName, + Assets.images.sparky.bumper.c.inactive.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + group('SparkyBumper', () { + flameTester.test('"a" loads correctly', (game) async { + final sparkyBumper = SparkyBumper.a(); + await game.ensureAdd(sparkyBumper); + expect(game.contains(sparkyBumper), isTrue); + }); + + flameTester.test('"b" loads correctly', (game) async { + final sparkyBumper = SparkyBumper.b(); + await game.ensureAdd(sparkyBumper); + expect(game.contains(sparkyBumper), isTrue); + }); + + flameTester.test('"c" loads correctly', (game) async { + final sparkyBumper = SparkyBumper.c(); + await game.ensureAdd(sparkyBumper); + expect(game.contains(sparkyBumper), isTrue); + }); + + // 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 = MockSparkyBumperCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SparkyBumperState.active, + ); + when(bloc.close).thenAnswer((_) async {}); + final sparkyBumper = SparkyBumper.test(bloc: bloc); + + await game.ensureAdd(sparkyBumper); + game.remove(sparkyBumper); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final sparkyBumper = SparkyBumper.a( + children: [component], + ); + await game.ensureAdd(sparkyBumper); + expect(sparkyBumper.children, contains(component)); + }); + + flameTester.test('a SparkyBumperBallContactBehavior', (game) async { + final sparkyBumper = SparkyBumper.a(); + await game.ensureAdd(sparkyBumper); + expect( + sparkyBumper.children + .whereType() + .single, + isNotNull, + ); + }); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/sparky_bumper_test.dart b/packages/pinball_components/test/src/components/sparky_bumper_test.dart deleted file mode 100644 index a2fcc5ed..00000000 --- a/packages/pinball_components/test/src/components/sparky_bumper_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame/components.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 assets = [ - Assets.images.sparky.bumper.a.active.keyName, - Assets.images.sparky.bumper.a.inactive.keyName, - Assets.images.sparky.bumper.b.active.keyName, - Assets.images.sparky.bumper.b.inactive.keyName, - Assets.images.sparky.bumper.c.active.keyName, - Assets.images.sparky.bumper.c.inactive.keyName, - ]; - final flameTester = FlameTester(() => TestGame(assets)); - - group('SparkyBumper', () { - flameTester.test('"a" loads correctly', (game) async { - final bumper = SparkyBumper.a(); - await game.ensureAdd(bumper); - - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('"b" loads correctly', (game) async { - final bumper = SparkyBumper.b(); - await game.ensureAdd(bumper); - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('"c" loads correctly', (game) async { - final bumper = SparkyBumper.c(); - await game.ensureAdd(bumper); - expect(game.contains(bumper), isTrue); - }); - - flameTester.test('animate switches between on and off sprites', - (game) async { - final bumper = SparkyBumper.a(); - await game.ensureAdd(bumper); - - final spriteGroupComponent = bumper.firstChild()!; - - expect( - spriteGroupComponent.current, - equals(SparkyBumperSpriteState.active), - ); - - final future = bumper.animate(); - - expect( - spriteGroupComponent.current, - equals(SparkyBumperSpriteState.inactive), - ); - - await future; - - expect( - spriteGroupComponent.current, - equals(SparkyBumperSpriteState.active), - ); - }); - }); -} diff --git a/packages/pinball_flame/lib/pinball_flame.dart b/packages/pinball_flame/lib/pinball_flame.dart index 709e7627..7eb4c3a9 100644 --- a/packages/pinball_flame/lib/pinball_flame.dart +++ b/packages/pinball_flame/lib/pinball_flame.dart @@ -2,5 +2,7 @@ library pinball_flame; export 'src/blueprint.dart'; export 'src/component_controller.dart'; +export 'src/contact_behavior.dart'; export 'src/keyboard_input_controller.dart'; +export 'src/parent_is_a.dart'; export 'src/sprite_animation.dart'; diff --git a/packages/pinball_flame/lib/src/contact_behavior.dart b/packages/pinball_flame/lib/src/contact_behavior.dart new file mode 100644 index 00000000..79112398 --- /dev/null +++ b/packages/pinball_flame/lib/src/contact_behavior.dart @@ -0,0 +1,95 @@ +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Appends a new [ContactCallbacks] to the parent. +/// +/// This is a convenience class for adding a [ContactCallbacks] to the parent. +/// In constract with just adding a [ContactCallbacks] to the parent's body +/// userData, this class respects the previous [ContactCallbacks] in the +/// parent's body userData, if any. Hence, it avoids overriding any previous +/// [ContactCallbacks] in the parent. +/// +/// It does so by grouping the [ContactCallbacks] in a [_ContactCallbacksGroup], +/// and resetting the parent's userData accordingly. +// TODO(alestiago): Make use of generics to infer the type of the contact. +// https://github.com/VGVentures/pinball/pull/234#discussion_r859182267 +// TODO(alestiago): Consider if there is a need to support adjusting a fixture's +// userData. +class ContactBehavior extends Component + with ContactCallbacks, ParentIsA { + @override + @mustCallSuper + Future onLoad() async { + final userData = parent.body.userData; + if (userData is _ContactCallbacksGroup) { + userData.addContactCallbacks(this); + } else if (userData is ContactCallbacks) { + final contactCallbacksGroup = _ContactCallbacksGroup() + ..addContactCallbacks(userData) + ..addContactCallbacks(this); + parent.body.userData = contactCallbacksGroup; + } else { + parent.body.userData = this; + } + } +} + +class _ContactCallbacksGroup implements ContactCallbacks { + final List _contactCallbacks = []; + + @override + @mustCallSuper + void beginContact(Object other, Contact contact) { + onBeginContact?.call(other, contact); + for (final callback in _contactCallbacks) { + callback.beginContact(other, contact); + } + } + + @override + @mustCallSuper + void endContact(Object other, Contact contact) { + onEndContact?.call(other, contact); + for (final callback in _contactCallbacks) { + callback.endContact(other, contact); + } + } + + @override + @mustCallSuper + void preSolve(Object other, Contact contact, Manifold oldManifold) { + onPreSolve?.call(other, contact, oldManifold); + for (final callback in _contactCallbacks) { + callback.preSolve(other, contact, oldManifold); + } + } + + @override + @mustCallSuper + void postSolve(Object other, Contact contact, ContactImpulse impulse) { + onPostSolve?.call(other, contact, impulse); + for (final callback in _contactCallbacks) { + callback.postSolve(other, contact, impulse); + } + } + + void addContactCallbacks(ContactCallbacks callback) { + _contactCallbacks.add(callback); + } + + @override + void Function(Object other, Contact contact)? onBeginContact; + + @override + void Function(Object other, Contact contact)? onEndContact; + + @override + void Function(Object other, Contact contact, ContactImpulse impulse)? + onPostSolve; + + @override + void Function(Object other, Contact contact, Manifold oldManifold)? + onPreSolve; +} diff --git a/packages/pinball_flame/lib/src/parent_is_a.dart b/packages/pinball_flame/lib/src/parent_is_a.dart new file mode 100644 index 00000000..19159c89 --- /dev/null +++ b/packages/pinball_flame/lib/src/parent_is_a.dart @@ -0,0 +1,15 @@ +import 'package:flame/components.dart'; + +// TODO(alestiago): Remove once the following is merged: +// https://github.com/flame-engine/flame/pull/1566 + +/// A mixin that ensures a parent is of the given type [T]. +mixin ParentIsA on Component { + @override + T get parent => super.parent! as T; + + @override + Future? addToParent(covariant T parent) { + return super.addToParent(parent); + } +} diff --git a/packages/pinball_flame/pubspec.yaml b/packages/pinball_flame/pubspec.yaml index ad8ec131..89caf5bb 100644 --- a/packages/pinball_flame/pubspec.yaml +++ b/packages/pinball_flame/pubspec.yaml @@ -8,7 +8,11 @@ environment: dependencies: flame: ^1.1.1 - flame_forge2d: ^0.11.0 + flame_forge2d: + git: + url: https://github.com/flame-engine/flame/ + path: packages/flame_forge2d/ + ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: sdk: flutter diff --git a/packages/pinball_flame/test/helpers/mocks.dart b/packages/pinball_flame/test/helpers/mocks.dart index bf96390d..1c5042ff 100644 --- a/packages/pinball_flame/test/helpers/mocks.dart +++ b/packages/pinball_flame/test/helpers/mocks.dart @@ -4,7 +4,4 @@ import 'package:mocktail/mocktail.dart'; class MockForge2DGame extends Mock implements Forge2DGame {} -class MockContactCallback extends Mock - implements ContactCallback {} - class MockComponent extends Mock implements Component {} diff --git a/packages/pinball_flame/test/src/contact_behavior_test.dart b/packages/pinball_flame/test/src/contact_behavior_test.dart new file mode 100644 index 00000000..630156ed --- /dev/null +++ b/packages/pinball_flame/test/src/contact_behavior_test.dart @@ -0,0 +1,153 @@ +// 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_flame/pinball_flame.dart'; + +class _TestBodyComponent extends BodyComponent { + @override + Body createBody() => world.createBody(BodyDef()); +} + +class _TestContactBehavior extends ContactBehavior { + int beginContactCallsCount = 0; + @override + void beginContact(Object other, Contact contact) { + beginContactCallsCount++; + super.beginContact(other, contact); + } + + int endContactCallsCount = 0; + @override + void endContact(Object other, Contact contact) { + endContactCallsCount++; + super.endContact(other, contact); + } + + int preSolveContactCallsCount = 0; + @override + void preSolve(Object other, Contact contact, Manifold oldManifold) { + preSolveContactCallsCount++; + super.preSolve(other, contact, oldManifold); + } + + int postSolveContactCallsCount = 0; + @override + void postSolve(Object other, Contact contact, ContactImpulse impulse) { + postSolveContactCallsCount++; + super.postSolve(other, contact, impulse); + } +} + +class _MockContactCallbacks extends Mock implements ContactCallbacks {} + +class _MockContact extends Mock implements Contact {} + +class _MockManifold extends Mock implements Manifold {} + +class _MockContactImpulse extends Mock implements ContactImpulse {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(Forge2DGame.new); + + group('ContactBehavior', () { + late Object other; + late Contact contact; + late Manifold manifold; + late ContactImpulse contactImpulse; + + setUp(() { + other = Object(); + contact = _MockContact(); + manifold = _MockManifold(); + contactImpulse = _MockContactImpulse(); + }); + + flameTester.test( + 'should add a new ContactCallbacks to the parent', + (game) async { + final parent = _TestBodyComponent(); + final contactBehavior = ContactBehavior(); + await parent.add(contactBehavior); + await game.ensureAdd(parent); + + expect(parent.body.userData, contactBehavior); + }, + ); + + flameTester.test( + "should respect the previous ContactCallbacks in the parent's userData", + (game) async { + final parent = _TestBodyComponent(); + await game.ensureAdd(parent); + final contactCallbacks1 = _MockContactCallbacks(); + parent.body.userData = contactCallbacks1; + + final contactBehavior = ContactBehavior(); + await parent.ensureAdd(contactBehavior); + + final contactCallbacks = parent.body.userData! as ContactCallbacks; + + contactCallbacks.beginContact(other, contact); + verify( + () => contactCallbacks1.beginContact(other, contact), + ).called(1); + + contactCallbacks.endContact(other, contact); + verify( + () => contactCallbacks1.endContact(other, contact), + ).called(1); + + contactCallbacks.preSolve(other, contact, manifold); + verify( + () => contactCallbacks1.preSolve(other, contact, manifold), + ).called(1); + + contactCallbacks.postSolve(other, contact, contactImpulse); + verify( + () => contactCallbacks1.postSolve(other, contact, contactImpulse), + ).called(1); + }, + ); + + flameTester.test('can group multiple ContactBehaviors and keep listening', + (game) async { + final parent = _TestBodyComponent(); + await game.ensureAdd(parent); + + final contactBehavior1 = _TestContactBehavior(); + final contactBehavior2 = _TestContactBehavior(); + final contactBehavior3 = _TestContactBehavior(); + await parent.ensureAddAll([ + contactBehavior1, + contactBehavior2, + contactBehavior3, + ]); + + final contactCallbacks = parent.body.userData! as ContactCallbacks; + + contactCallbacks.beginContact(other, contact); + expect(contactBehavior1.beginContactCallsCount, equals(1)); + expect(contactBehavior2.beginContactCallsCount, equals(1)); + expect(contactBehavior3.beginContactCallsCount, equals(1)); + + contactCallbacks.endContact(other, contact); + expect(contactBehavior1.endContactCallsCount, equals(1)); + expect(contactBehavior2.endContactCallsCount, equals(1)); + expect(contactBehavior3.endContactCallsCount, equals(1)); + + contactCallbacks.preSolve(other, contact, manifold); + expect(contactBehavior1.preSolveContactCallsCount, equals(1)); + expect(contactBehavior2.preSolveContactCallsCount, equals(1)); + expect(contactBehavior3.preSolveContactCallsCount, equals(1)); + + contactCallbacks.postSolve(other, contact, contactImpulse); + expect(contactBehavior1.postSolveContactCallsCount, equals(1)); + expect(contactBehavior2.postSolveContactCallsCount, equals(1)); + expect(contactBehavior3.postSolveContactCallsCount, equals(1)); + }); + }); +} diff --git a/packages/pinball_theme/lib/pinball_theme.dart b/packages/pinball_theme/lib/pinball_theme.dart index 139a70dc..c8f9b53e 100644 --- a/packages/pinball_theme/lib/pinball_theme.dart +++ b/packages/pinball_theme/lib/pinball_theme.dart @@ -1,5 +1,4 @@ library pinball_theme; export 'src/generated/generated.dart'; -export 'src/pinball_theme.dart'; export 'src/themes/themes.dart'; diff --git a/packages/pinball_theme/lib/src/pinball_theme.dart b/packages/pinball_theme/lib/src/pinball_theme.dart deleted file mode 100644 index a766a129..00000000 --- a/packages/pinball_theme/lib/src/pinball_theme.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:pinball_theme/pinball_theme.dart'; - -/// {@template pinball_theme} -/// Defines all theme assets and attributes. -/// -/// Game components should have a getter specified here to load their -/// corresponding assets for the game. -/// {@endtemplate} -class PinballTheme extends Equatable { - /// {@macro pinball_theme} - const PinballTheme({ - required CharacterTheme characterTheme, - }) : _characterTheme = characterTheme; - - final CharacterTheme _characterTheme; - - /// [CharacterTheme] for the chosen character. - CharacterTheme get characterTheme => _characterTheme; - - @override - List get props => [_characterTheme]; -} diff --git a/packages/pinball_theme/test/src/pinball_theme_test.dart b/packages/pinball_theme/test/src/pinball_theme_test.dart deleted file mode 100644 index 899eec64..00000000 --- a/packages/pinball_theme/test/src/pinball_theme_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball_theme/pinball_theme.dart'; - -void main() { - group('PinballTheme', () { - const characterTheme = SparkyTheme(); - - test('can be instantiated', () { - expect(PinballTheme(characterTheme: characterTheme), isNotNull); - }); - - test('supports value equality', () { - expect( - PinballTheme(characterTheme: characterTheme), - equals(PinballTheme(characterTheme: characterTheme)), - ); - }); - - test('characterTheme is correct', () { - expect( - PinballTheme(characterTheme: characterTheme).characterTheme, - equals(characterTheme), - ); - }); - }); -} diff --git a/pubspec.lock b/pubspec.lock index 1a502f37..9ee8ae6c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -214,9 +214,11 @@ packages: flame_forge2d: dependency: "direct main" description: - name: flame_forge2d - url: "https://pub.dartlang.org" - source: hosted + path: "packages/flame_forge2d" + ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f + resolved-ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f + url: "https://github.com/flame-engine/flame/" + source: git version: "0.11.0" flame_test: dependency: "direct dev" diff --git a/pubspec.yaml b/pubspec.yaml index f17ea07a..48c570c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,11 @@ dependencies: equatable: ^2.0.3 flame: ^1.1.1 flame_bloc: ^1.2.0 - flame_forge2d: ^0.11.0 + flame_forge2d: + git: + url: https://github.com/flame-engine/flame/ + path: packages/flame_forge2d/ + ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f flutter: sdk: flutter flutter_bloc: ^8.0.1 @@ -48,6 +52,7 @@ flutter: assets: - assets/images/components/ - assets/images/bonus_animation/ + - assets/images/score/ flutter_gen: line_length: 80 diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart index 37e14f73..3711105e 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -4,27 +4,69 @@ import 'package:pinball/game/game.dart'; void main() { group('GameBloc', () { - test('initial state has 3 balls and empty score', () { + test('initial state has 3 rounds and empty score', () { final gameBloc = GameBloc(); expect(gameBloc.state.score, equals(0)); - expect(gameBloc.state.balls, equals(3)); + expect(gameBloc.state.rounds, equals(3)); }); - group('LostBall', () { + group('RoundLost', () { blocTest( - 'decreases number of balls', + 'decreases number of rounds ' + 'when there are already available rounds', build: GameBloc.new, act: (bloc) { - bloc.add(const BallLost()); + bloc.add(const RoundLost()); }, expect: () => [ const GameState( score: 0, - balls: 2, + multiplier: 1, + rounds: 2, bonusHistory: [], ), ], ); + + blocTest( + 'apply multiplier to score ' + 'when round is lost', + build: GameBloc.new, + seed: () => const GameState( + score: 5, + multiplier: 3, + rounds: 2, + bonusHistory: [], + ), + act: (bloc) { + bloc.add(const RoundLost()); + }, + expect: () => [ + isA() + ..having((state) => state.score, 'score', 15) + ..having((state) => state.rounds, 'rounds', 1), + ], + ); + + blocTest( + 'resets multiplier ' + 'when round is lost', + build: GameBloc.new, + seed: () => const GameState( + score: 5, + multiplier: 3, + rounds: 2, + bonusHistory: [], + ), + act: (bloc) { + bloc.add(const RoundLost()); + }, + expect: () => [ + isA() + ..having((state) => state.multiplier, 'multiplier', 1) + ..having((state) => state.rounds, 'rounds', 1), + ], + ); }); group('Scored', () { @@ -36,16 +78,12 @@ void main() { ..add(const Scored(points: 2)) ..add(const Scored(points: 3)), expect: () => [ - const GameState( - score: 2, - balls: 3, - bonusHistory: [], - ), - const GameState( - score: 5, - balls: 3, - bonusHistory: [], - ), + isA() + ..having((state) => state.score, 'score', 2) + ..having((state) => state.isGameOver, 'isGameOver', false), + isA() + ..having((state) => state.score, 'score', 5) + ..having((state) => state.isGameOver, 'isGameOver', false), ], ); @@ -54,27 +92,85 @@ void main() { 'when game is over', build: GameBloc.new, act: (bloc) { - for (var i = 0; i < bloc.state.balls; i++) { - bloc.add(const BallLost()); + for (var i = 0; i < bloc.state.rounds; i++) { + bloc.add(const RoundLost()); } bloc.add(const Scored(points: 2)); }, expect: () => [ - const GameState( - score: 0, - balls: 2, - bonusHistory: [], - ), - const GameState( - score: 0, - balls: 1, - bonusHistory: [], - ), - const GameState( - score: 0, - balls: 0, - bonusHistory: [], - ), + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.rounds, 'rounds', 2) + ..having((state) => state.isGameOver, 'isGameOver', false), + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.rounds, 'rounds', 1) + ..having((state) => state.isGameOver, 'isGameOver', false), + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.rounds, 'rounds', 0) + ..having((state) => state.isGameOver, 'isGameOver', true), + ], + ); + }); + + group('MultiplierIncreased', () { + blocTest( + 'increases multiplier ' + 'when multiplier is below 6 and game is not over', + build: GameBloc.new, + act: (bloc) => bloc + ..add(const MultiplierIncreased()) + ..add(const MultiplierIncreased()), + expect: () => [ + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.multiplier, 'multiplier', 2) + ..having((state) => state.isGameOver, 'isGameOver', false), + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.multiplier, 'multiplier', 3) + ..having((state) => state.isGameOver, 'isGameOver', false), + ], + ); + + blocTest( + "doesn't increase multiplier " + 'when multiplier is 6 and game is not over', + build: GameBloc.new, + seed: () => const GameState( + score: 0, + multiplier: 6, + rounds: 3, + bonusHistory: [], + ), + act: (bloc) => bloc..add(const MultiplierIncreased()), + expect: () => const [], + ); + + blocTest( + "doesn't increase multiplier " + 'when game is over', + build: GameBloc.new, + act: (bloc) { + for (var i = 0; i < bloc.state.rounds; i++) { + bloc.add(const RoundLost()); + } + bloc.add(const MultiplierIncreased()); + }, + expect: () => [ + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.multiplier, 'multiplier', 1) + ..having((state) => state.isGameOver, 'isGameOver', false), + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.multiplier, 'multiplier', 1) + ..having((state) => state.isGameOver, 'isGameOver', false), + isA() + ..having((state) => state.score, 'score', 0) + ..having((state) => state.multiplier, 'multiplier', 1) + ..having((state) => state.isGameOver, 'isGameOver', true), ], ); }); @@ -88,17 +184,19 @@ void main() { act: (bloc) => bloc ..add(const BonusActivated(GameBonus.googleWord)) ..add(const BonusActivated(GameBonus.dashNest)), - expect: () => const [ - GameState( - score: 0, - balls: 3, - bonusHistory: [GameBonus.googleWord], - ), - GameState( - score: 0, - balls: 3, - bonusHistory: [GameBonus.googleWord, GameBonus.dashNest], - ), + expect: () => [ + isA() + ..having( + (state) => state.bonusHistory, + 'bonusHistory', + [GameBonus.googleWord], + ), + isA() + ..having( + (state) => state.bonusHistory, + 'bonusHistory', + [GameBonus.googleWord, GameBonus.dashNest], + ), ], ); }, @@ -109,12 +207,13 @@ void main() { 'adds game bonus', build: GameBloc.new, act: (bloc) => bloc..add(const SparkyTurboChargeActivated()), - expect: () => const [ - GameState( - score: 0, - balls: 3, - bonusHistory: [GameBonus.sparkyTurboCharge], - ), + expect: () => [ + isA() + ..having( + (state) => state.bonusHistory, + 'bonusHistory', + [GameBonus.sparkyTurboCharge], + ), ], ); }); diff --git a/test/game/bloc/game_event_test.dart b/test/game/bloc/game_event_test.dart index d7d587bd..6a39bd67 100644 --- a/test/game/bloc/game_event_test.dart +++ b/test/game/bloc/game_event_test.dart @@ -5,15 +5,15 @@ import 'package:pinball/game/game.dart'; void main() { group('GameEvent', () { - group('BallLost', () { + group('RoundLost', () { test('can be instantiated', () { - expect(const BallLost(), isNotNull); + expect(const RoundLost(), isNotNull); }); test('supports value equality', () { expect( - BallLost(), - equals(const BallLost()), + RoundLost(), + equals(const RoundLost()), ); }); }); @@ -41,6 +41,19 @@ void main() { }); }); + group('MultiplierIncreased', () { + test('can be instantiated', () { + expect(const MultiplierIncreased(), isNotNull); + }); + + test('supports value equality', () { + expect( + MultiplierIncreased(), + equals(const MultiplierIncreased()), + ); + }); + }); + group('BonusActivated', () { test('can be instantiated', () { expect(const BonusActivated(GameBonus.dashNest), isNotNull); diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index 8170346f..add25e05 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -9,13 +9,15 @@ void main() { expect( GameState( score: 0, - balls: 0, + multiplier: 1, + rounds: 3, bonusHistory: const [], ), equals( const GameState( score: 0, - balls: 0, + multiplier: 1, + rounds: 3, bonusHistory: [], ), ), @@ -27,7 +29,8 @@ void main() { expect( const GameState( score: 0, - balls: 0, + multiplier: 1, + rounds: 3, bonusHistory: [], ), isNotNull, @@ -37,12 +40,13 @@ void main() { test( 'throws AssertionError ' - 'when balls are negative', + 'when score is negative', () { expect( () => GameState( - balls: -1, - score: 0, + score: -1, + multiplier: 1, + rounds: 3, bonusHistory: const [], ), throwsAssertionError, @@ -52,12 +56,29 @@ void main() { test( 'throws AssertionError ' - 'when score is negative', + 'when multiplier is less than 1', () { expect( () => GameState( - balls: 0, - score: -1, + score: 1, + multiplier: 0, + rounds: 3, + bonusHistory: const [], + ), + throwsAssertionError, + ); + }, + ); + + test( + 'throws AssertionError ' + 'when rounds is negative', + () { + expect( + () => GameState( + score: 1, + multiplier: 1, + rounds: -1, bonusHistory: const [], ), throwsAssertionError, @@ -68,10 +89,11 @@ void main() { group('isGameOver', () { test( 'is true ' - 'when no balls are left', () { + 'when no rounds are left', () { const gameState = GameState( - balls: 0, score: 0, + multiplier: 1, + rounds: 0, bonusHistory: [], ); expect(gameState.isGameOver, isTrue); @@ -79,10 +101,11 @@ void main() { test( 'is false ' - 'when one 1 ball left', () { + 'when one 1 round left', () { const gameState = GameState( - balls: 1, score: 0, + multiplier: 1, + rounds: 1, bonusHistory: [], ); expect(gameState.isGameOver, isFalse); @@ -95,8 +118,9 @@ void main() { 'when scored is decreased', () { const gameState = GameState( - balls: 0, score: 2, + multiplier: 1, + rounds: 3, bonusHistory: [], ); expect( @@ -111,8 +135,9 @@ void main() { 'when no argument specified', () { const gameState = GameState( - balls: 0, score: 2, + multiplier: 1, + rounds: 3, bonusHistory: [], ); expect( @@ -128,12 +153,14 @@ void main() { () { const gameState = GameState( score: 2, - balls: 0, + multiplier: 1, + rounds: 3, bonusHistory: [], ); final otherGameState = GameState( score: gameState.score + 1, - balls: gameState.balls + 1, + multiplier: gameState.multiplier + 1, + rounds: gameState.rounds + 1, bonusHistory: const [GameBonus.googleWord], ); expect(gameState, isNot(equals(otherGameState))); @@ -141,7 +168,8 @@ void main() { expect( gameState.copyWith( score: otherGameState.score, - balls: otherGameState.balls, + multiplier: otherGameState.multiplier, + rounds: otherGameState.rounds, bonusHistory: otherGameState.bonusHistory, ), equals(otherGameState), diff --git a/test/game/components/alien_zone_test.dart b/test/game/components/alien_zone_test.dart deleted file mode 100644 index de4e58fc..00000000 --- a/test/game/components/alien_zone_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'dart:ui'; - -import 'package:bloc_test/bloc_test.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 '../../helpers/helpers.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final assets = [ - Assets.images.alienBumper.a.active.keyName, - Assets.images.alienBumper.a.inactive.keyName, - Assets.images.alienBumper.b.active.keyName, - Assets.images.alienBumper.b.inactive.keyName, - ]; - final flameTester = FlameTester(() => EmptyPinballTestGame(assets)); - - group('AlienZone', () { - flameTester.test( - 'loads correctly', - (game) async { - final alienZone = AlienZone(); - await game.ensureAdd(alienZone); - - expect(game.contains(alienZone), isTrue); - }, - ); - - group('loads', () { - flameTester.test( - 'two AlienBumper', - (game) async { - final alienZone = AlienZone(); - await game.ensureAdd(alienZone); - - expect( - alienZone.descendants().whereType().length, - equals(2), - ); - }, - ); - }); - - group('bumpers', () { - late GameBloc gameBloc; - - setUp(() { - gameBloc = MockGameBloc(); - whenListen( - gameBloc, - const Stream.empty(), - initialState: const GameState.initial(), - ); - }); - - final flameBlocTester = FlameBlocTester( - gameBuilder: EmptyPinballTestGame.new, - blocBuilder: () => gameBloc, - assets: assets, - ); - - flameTester.test('call animate on contact', (game) async { - final contactCallback = AlienBumperBallContactCallback(); - final bumper = MockAlienBumper(); - final ball = MockBall(); - - when(bumper.animate).thenAnswer((_) async {}); - - contactCallback.begin(bumper, ball, MockContact()); - - verify(bumper.animate).called(1); - }); - - flameBlocTester.testGameWidget( - 'add Scored event', - setUp: (game, tester) async { - final ball = Ball(baseColor: const Color(0xFF00FFFF)); - final alienZone = AlienZone(); - - await game.ensureAdd(alienZone); - await game.ensureAdd(ball); - game.addContactCallback(BallScorePointsCallback(game)); - - final bumpers = alienZone.descendants().whereType(); - - for (final bumper in bumpers) { - beginContact(game, bumper, ball); - verify( - () => gameBloc.add( - Scored(points: bumper.points), - ), - ).called(1); - } - }, - ); - }); - }); -} diff --git a/test/game/components/android_acres_test.dart b/test/game/components/android_acres_test.dart new file mode 100644 index 00000000..419524c6 --- /dev/null +++ b/test/game/components/android_acres_test.dart @@ -0,0 +1,90 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.spaceship.ramp.boardOpening.keyName, + Assets.images.spaceship.ramp.railingForeground.keyName, + Assets.images.spaceship.ramp.railingBackground.keyName, + Assets.images.spaceship.ramp.main.keyName, + Assets.images.spaceship.ramp.arrow.inactive.keyName, + Assets.images.spaceship.ramp.arrow.active1.keyName, + Assets.images.spaceship.ramp.arrow.active2.keyName, + Assets.images.spaceship.ramp.arrow.active3.keyName, + Assets.images.spaceship.ramp.arrow.active4.keyName, + Assets.images.spaceship.ramp.arrow.active5.keyName, + Assets.images.spaceship.rail.main.keyName, + Assets.images.spaceship.rail.exit.keyName, + Assets.images.androidBumper.a.lit.keyName, + Assets.images.androidBumper.a.dimmed.keyName, + Assets.images.androidBumper.b.lit.keyName, + Assets.images.androidBumper.b.dimmed.keyName, + ]; + final flameTester = FlameTester( + () => EmptyPinballTestGame(assets: assets), + ); + + group('AndroidAcres', () { + flameTester.test( + 'loads correctly', + (game) async { + await game.addFromBlueprint(AndroidAcres()); + await game.ready(); + }, + ); + + group('loads', () { + flameTester.test( + 'a Spaceship', + (game) async { + expect( + AndroidAcres().blueprints.whereType().single, + isNotNull, + ); + }, + ); + + flameTester.test( + 'a SpaceshipRamp', + (game) async { + expect( + AndroidAcres().blueprints.whereType().single, + isNotNull, + ); + }, + ); + + flameTester.test( + 'a SpaceshipRail', + (game) async { + expect( + AndroidAcres().blueprints.whereType().single, + isNotNull, + ); + }, + ); + + flameTester.test( + 'two AndroidBumper', + (game) async { + final androidZone = AndroidAcres(); + await game.addFromBlueprint(androidZone); + await game.ready(); + + expect( + game.descendants().whereType().length, + equals(2), + ); + }, + ); + }); + }); +} diff --git a/test/game/components/board_test.dart b/test/game/components/board_test.dart index 48a3f2c5..63b7251b 100644 --- a/test/game/components/board_test.dart +++ b/test/game/components/board_test.dart @@ -26,7 +26,9 @@ void main() { Assets.images.flipper.left.keyName, Assets.images.flipper.right.keyName, ]; - final flameTester = FlameTester(() => EmptyPinballTestGame(assets)); + final flameTester = FlameTester( + () => EmptyPinballTestGame(assets: assets), + ); group('Board', () { flameTester.test( diff --git a/test/game/components/controlled_ball_test.dart b/test/game/components/controlled_ball_test.dart index e615d508..c84ddaa7 100644 --- a/test/game/components/controlled_ball_test.dart +++ b/test/game/components/controlled_ball_test.dart @@ -53,16 +53,39 @@ void main() { }); flameBlocTester.testGameWidget( - 'lost adds BallLost to GameBloc', + "lost doesn't adds RoundLost to GameBloc " + 'when there are balls left', + setUp: (game, tester) async { + final controller = BallController(ball); + await ball.add(controller); + await game.ensureAdd(ball); + + final otherBall = Ball(baseColor: const Color(0xFF00FFFF)); + final otherController = BallController(otherBall); + await otherBall.add(otherController); + await game.ensureAdd(otherBall); + + controller.lost(); + await game.ready(); + }, + verify: (game, tester) async { + verifyNever(() => gameBloc.add(const RoundLost())); + }, + ); + + flameBlocTester.testGameWidget( + 'lost adds RoundLost to GameBloc ' + 'when there are no balls left', setUp: (game, tester) async { final controller = BallController(ball); await ball.add(controller); await game.ensureAdd(ball); controller.lost(); + await game.ready(); }, verify: (game, tester) async { - verify(() => gameBloc.add(const BallLost())).called(1); + verify(() => gameBloc.add(const RoundLost())).called(1); }, ); diff --git a/test/game/components/controlled_flipper_test.dart b/test/game/components/controlled_flipper_test.dart index 1b2a7e43..36a8161b 100644 --- a/test/game/components/controlled_flipper_test.dart +++ b/test/game/components/controlled_flipper_test.dart @@ -15,7 +15,9 @@ void main() { Assets.images.flipper.left.keyName, Assets.images.flipper.right.keyName, ]; - final flameTester = FlameTester(() => EmptyPinballTestGame(assets)); + final flameTester = FlameTester( + () => EmptyPinballTestGame(assets: assets), + ); final flameBlocTester = FlameBlocTester( gameBuilder: EmptyPinballTestGame.new, @@ -23,7 +25,8 @@ void main() { final bloc = MockGameBloc(); const state = GameState( score: 0, - balls: 0, + multiplier: 1, + rounds: 0, bonusHistory: [], ); whenListen(bloc, Stream.value(state), initialState: state); diff --git a/test/game/components/controlled_plunger_test.dart b/test/game/components/controlled_plunger_test.dart index eee2bcb0..a39bdef6 100644 --- a/test/game/components/controlled_plunger_test.dart +++ b/test/game/components/controlled_plunger_test.dart @@ -20,7 +20,8 @@ void main() { final bloc = MockGameBloc(); const state = GameState( score: 0, - balls: 0, + multiplier: 1, + rounds: 0, bonusHistory: [], ); whenListen(bloc, Stream.value(state), initialState: state); diff --git a/test/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior_test.dart b/test/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior_test.dart new file mode 100644 index 00000000..c1834516 --- /dev/null +++ b/test/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior_test.dart @@ -0,0 +1,84 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.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/flutter_forest/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + group('FlutterForestBonusBehavior', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + ); + + flameBlocTester.testGameWidget( + 'adds GameBonus.dashNest to the game when all bumpers are active', + setUp: (game, tester) async { + final behavior = FlutterForestBonusBehavior(); + final parent = FlutterForest.test(); + final bumpers = [ + DashNestBumper.test(bloc: DashNestBumperCubit()), + DashNestBumper.test(bloc: DashNestBumperCubit()), + DashNestBumper.test(bloc: DashNestBumperCubit()), + ]; + await parent.addAll(bumpers); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + + for (final bumper in bumpers) { + bumper.bloc.onBallContacted(); + } + await tester.pump(); + + verify( + () => gameBloc.add(const BonusActivated(GameBonus.dashNest)), + ).called(1); + }, + ); + + flameBlocTester.testGameWidget( + 'adds a new ball to the game when all bumpers are active', + setUp: (game, tester) async { + final behavior = FlutterForestBonusBehavior(); + final parent = FlutterForest.test(); + final bumpers = [ + DashNestBumper.test(bloc: DashNestBumperCubit()), + DashNestBumper.test(bloc: DashNestBumperCubit()), + DashNestBumper.test(bloc: DashNestBumperCubit()), + ]; + await parent.addAll(bumpers); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + + for (final bumper in bumpers) { + bumper.bloc.onBallContacted(); + } + await game.ready(); + + expect( + game.descendants().whereType().single, + isNotNull, + ); + }, + ); + }); +} diff --git a/test/game/components/flutter_forest/flutter_forest_test.dart b/test/game/components/flutter_forest/flutter_forest_test.dart new file mode 100644 index 00000000..4f32e0f4 --- /dev/null +++ b/test/game/components/flutter_forest/flutter_forest_test.dart @@ -0,0 +1,80 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.dash.bumper.main.active.keyName, + Assets.images.dash.bumper.main.inactive.keyName, + Assets.images.dash.bumper.a.active.keyName, + Assets.images.dash.bumper.a.inactive.keyName, + Assets.images.dash.bumper.b.active.keyName, + Assets.images.dash.bumper.b.inactive.keyName, + Assets.images.dash.animatronic.keyName, + Assets.images.signpost.inactive.keyName, + Assets.images.signpost.active1.keyName, + Assets.images.signpost.active2.keyName, + Assets.images.signpost.active3.keyName, + ]; + final flameTester = FlameTester( + () => EmptyPinballTestGame(assets: assets), + ); + + group('FlutterForest', () { + flameTester.test( + 'loads correctly', + (game) async { + final flutterForest = FlutterForest(); + await game.ensureAdd(flutterForest); + expect(game.contains(flutterForest), isTrue); + }, + ); + + group('loads', () { + flameTester.test( + 'a Signpost', + (game) async { + final flutterForest = FlutterForest(); + await game.ensureAdd(flutterForest); + + expect( + flutterForest.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'a DashAnimatronic', + (game) async { + final flutterForest = FlutterForest(); + await game.ensureAdd(flutterForest); + + expect( + flutterForest.firstChild(), + isNotNull, + ); + }, + ); + + flameTester.test( + 'three DashNestBumper', + (game) async { + final flutterForest = FlutterForest(); + await game.ensureAdd(flutterForest); + + expect( + flutterForest.descendants().whereType().length, + equals(3), + ); + }, + ); + }); + }); +} diff --git a/test/game/components/flutter_forest_test.dart b/test/game/components/flutter_forest_test.dart deleted file mode 100644 index 388410d5..00000000 --- a/test/game/components/flutter_forest_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/rendering.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 '../../helpers/helpers.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final assets = [ - Assets.images.dash.bumper.main.active.keyName, - Assets.images.dash.bumper.main.inactive.keyName, - Assets.images.dash.bumper.a.active.keyName, - Assets.images.dash.bumper.a.inactive.keyName, - Assets.images.dash.bumper.b.active.keyName, - Assets.images.dash.bumper.b.inactive.keyName, - Assets.images.dash.animatronic.keyName, - Assets.images.signpost.inactive.keyName, - Assets.images.signpost.active1.keyName, - Assets.images.signpost.active2.keyName, - Assets.images.signpost.active3.keyName, - ]; - final flameTester = FlameTester(() => EmptyPinballTestGame(assets)); - - group('FlutterForest', () { - flameTester.test( - 'loads correctly', - (game) async { - final flutterForest = FlutterForest(); - await game.ensureAdd(flutterForest); - - expect(game.contains(flutterForest), isTrue); - }, - ); - - group('loads', () { - flameTester.test( - 'a Signpost', - (game) async { - final flutterForest = FlutterForest(); - await game.ensureAdd(flutterForest); - - expect( - flutterForest.descendants().whereType().length, - equals(1), - ); - }, - ); - - flameTester.test( - 'a DashAnimatronic', - (game) async { - final flutterForest = FlutterForest(); - await game.ensureAdd(flutterForest); - - expect( - flutterForest.firstChild(), - isNotNull, - ); - }, - ); - - flameTester.test( - 'three DashNestBumper', - (game) async { - final flutterForest = FlutterForest(); - await game.ensureAdd(flutterForest); - - expect( - flutterForest.descendants().whereType().length, - equals(3), - ); - }, - ); - }); - - group('bumpers', () { - late Ball ball; - late GameBloc gameBloc; - - setUp(() { - ball = Ball(baseColor: const Color(0xFF00FFFF)); - }); - - final flameBlocTester = FlameBlocTester( - gameBuilder: () => EmptyPinballTestGame(assets), - blocBuilder: () { - gameBloc = MockGameBloc(); - const state = GameState.initial(); - whenListen(gameBloc, Stream.value(state), initialState: state); - return gameBloc; - }, - assets: assets, - ); - - flameBlocTester.testGameWidget( - 'add Scored event', - setUp: (game, tester) async { - final flutterForest = FlutterForest(); - await game.ensureAddAll([ - flutterForest, - ball, - ]); - game.addContactCallback(BallScorePointsCallback(game)); - - final bumpers = flutterForest.descendants().whereType(); - - for (final bumper in bumpers) { - beginContact(game, bumper, ball); - verify( - () => gameBloc.add( - Scored(points: bumper.points), - ), - ).called(1); - } - }, - ); - - flameBlocTester.testGameWidget( - 'adds GameBonus.dashNest to the game when 3 bumpers are activated', - setUp: (game, _) async { - final ball = Ball(baseColor: const Color(0xFFFF0000)); - final flutterForest = FlutterForest(); - await game.ensureAddAll([flutterForest, ball]); - - final bumpers = flutterForest.children.whereType(); - expect(bumpers.length, equals(3)); - for (final bumper in bumpers) { - beginContact(game, bumper, ball); - await game.ready(); - - if (bumper == bumpers.last) { - verify( - () => gameBloc.add(const BonusActivated(GameBonus.dashNest)), - ).called(1); - } else { - verifyNever( - () => gameBloc.add(const BonusActivated(GameBonus.dashNest)), - ); - } - } - }, - ); - - flameBlocTester.testGameWidget( - 'deactivates bumpers when 3 are active', - setUp: (game, _) async { - final ball = Ball(baseColor: const Color(0xFFFF0000)); - final flutterForest = FlutterForest(); - await game.ensureAddAll([flutterForest, ball]); - - final bumpers = [ - MockDashNestBumper(), - MockDashNestBumper(), - MockDashNestBumper(), - ]; - - for (final bumper in bumpers) { - flutterForest.controller.activateBumper(bumper); - await game.ready(); - - if (bumper == bumpers.last) { - for (final bumper in bumpers) { - verify(bumper.deactivate).called(1); - } - } - } - }, - ); - }); - }); -} diff --git a/test/game/components/game_flow_controller_test.dart b/test/game/components/game_flow_controller_test.dart index 4efc7174..ef93892c 100644 --- a/test/game/components/game_flow_controller_test.dart +++ b/test/game/components/game_flow_controller_test.dart @@ -15,7 +15,8 @@ void main() { test('is true when the game over state has changed', () { final state = GameState( score: 10, - balls: 0, + multiplier: 1, + rounds: 0, bonusHistory: const [], ); @@ -57,8 +58,7 @@ void main() { when(game.firstChild).thenReturn(backboard); when(game.firstChild).thenReturn(cameraController); when(() => game.overlays).thenReturn(overlays); - when(() => game.theme) - .thenReturn(PinballTheme(characterTheme: DashTheme())); + when(() => game.characterTheme).thenReturn(DashTheme()); }); test( @@ -67,7 +67,8 @@ void main() { gameFlowController.onNewState( GameState( score: 10, - balls: 0, + multiplier: 1, + rounds: 0, bonusHistory: const [], ), ); diff --git a/test/game/components/google_word/behaviors/google_word_bonus_behavior_test.dart b/test/game/components/google_word/behaviors/google_word_bonus_behavior_test.dart new file mode 100644 index 00000000..deca61ee --- /dev/null +++ b/test/game/components/google_word/behaviors/google_word_bonus_behavior_test.dart @@ -0,0 +1,61 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/game/components/google_word/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('GoogleWordBonusBehaviors', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + ); + + flameBlocTester.testGameWidget( + 'adds GameBonus.googleWord to the game when all letters are activated', + setUp: (game, tester) async { + final behavior = GoogleWordBonusBehavior(); + final parent = GoogleWord.test(); + final letters = [ + GoogleLetter(0), + GoogleLetter(1), + GoogleLetter(2), + GoogleLetter(3), + GoogleLetter(4), + GoogleLetter(5), + ]; + await parent.addAll(letters); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + + for (final letter in letters) { + letter.bloc.onBallContacted(); + } + await tester.pump(); + + verify( + () => gameBloc.add(const BonusActivated(GameBonus.googleWord)), + ).called(1); + }, + ); + }); +} diff --git a/test/game/components/google_word/google_word_test.dart b/test/game/components/google_word/google_word_test.dart new file mode 100644 index 00000000..2d7d04e5 --- /dev/null +++ b/test/game/components/google_word/google_word_test.dart @@ -0,0 +1,26 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(EmptyPinballTestGame.new); + + group('GoogleWord', () { + flameTester.test( + 'loads the letters correctly', + (game) async { + const word = 'Google'; + final googleWord = GoogleWord(position: Vector2.zero()); + await game.ensureAdd(googleWord); + + final letters = googleWord.children.whereType(); + expect(letters.length, equals(word.length)); + }, + ); + }); +} diff --git a/test/game/components/google_word_test.dart b/test/game/components/google_word_test.dart deleted file mode 100644 index fee7bdd0..00000000 --- a/test/game/components/google_word_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockingjay/mockingjay.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('GoogleWord', () { - late GameBloc gameBloc; - - setUp(() { - gameBloc = MockGameBloc(); - whenListen( - gameBloc, - const Stream.empty(), - initialState: const GameState.initial(), - ); - }); - - final flameTester = FlameTester(EmptyPinballTestGame.new); - final flameBlocTester = FlameBlocTester( - gameBuilder: EmptyPinballTestGame.new, - blocBuilder: () => gameBloc, - ); - - flameTester.test( - 'loads the letters correctly', - (game) async { - const word = 'Google'; - final googleWord = GoogleWord(position: Vector2.zero()); - await game.ensureAdd(googleWord); - - final letters = googleWord.children.whereType(); - expect(letters.length, equals(word.length)); - }, - ); - - flameBlocTester.testGameWidget( - 'adds GameBonus.googleWord to the game when all letters are activated', - setUp: (game, _) async { - final ball = Ball(baseColor: const Color(0xFFFF0000)); - final googleWord = GoogleWord(position: Vector2.zero()); - await game.ensureAddAll([googleWord, ball]); - - final letters = googleWord.children.whereType(); - expect(letters, isNotEmpty); - for (final letter in letters) { - beginContact(game, letter, ball); - await game.ready(); - - if (letter == letters.last) { - verify( - () => gameBloc.add(const BonusActivated(GameBonus.googleWord)), - ).called(1); - } else { - verifyNever( - () => gameBloc.add(const BonusActivated(GameBonus.googleWord)), - ); - } - } - }, - ); - }); -} diff --git a/test/game/components/score_points_test.dart b/test/game/components/score_points_test.dart deleted file mode 100644 index dcd0ad82..00000000 --- a/test/game/components/score_points_test.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_audio/pinball_audio.dart'; -import 'package:pinball_components/pinball_components.dart'; - -import '../../helpers/helpers.dart'; - -class FakeScorePoints extends BodyComponent with ScorePoints { - @override - Body createBody() { - throw UnimplementedError(); - } - - @override - int get points => 2; -} - -void main() { - group('BallScorePointsCallback', () { - late PinballGame game; - late GameBloc bloc; - late PinballAudio audio; - late Ball ball; - late FakeScorePoints fakeScorePoints; - - setUp(() { - game = MockPinballGame(); - bloc = MockGameBloc(); - audio = MockPinballAudio(); - fakeScorePoints = FakeScorePoints(); - - ball = MockBall(); - final ballBody = MockBody(); - when(() => ball.body).thenReturn(ballBody); - when(() => ballBody.position).thenReturn(Vector2.all(4)); - }); - - setUpAll(() { - registerFallbackValue(FakeGameEvent()); - }); - - group('begin', () { - test( - 'emits Scored event with points', - () { - when(game.read).thenReturn(bloc); - when(() => game.audio).thenReturn(audio); - - BallScorePointsCallback(game).begin( - ball, - fakeScorePoints, - FakeContact(), - ); - - verify( - () => bloc.add( - Scored(points: fakeScorePoints.points), - ), - ).called(1); - }, - ); - - test( - 'plays a Score sound', - () { - when(game.read).thenReturn(bloc); - when(() => game.audio).thenReturn(audio); - - BallScorePointsCallback(game).begin( - ball, - fakeScorePoints, - FakeContact(), - ); - - verify(audio.score).called(1); - }, - ); - - test( - "adds a ScoreText component at Ball's position", - () { - when(game.read).thenReturn(bloc); - when(() => game.audio).thenReturn(audio); - - BallScorePointsCallback(game).begin( - ball, - fakeScorePoints, - FakeContact(), - ); - - verify( - () => game.add( - ScoreText( - text: fakeScorePoints.points.toString(), - position: ball.body.position, - ), - ), - ).called(1); - }, - ); - }); - }); -} diff --git a/test/game/components/scoring_behavior_test.dart b/test/game/components/scoring_behavior_test.dart new file mode 100644 index 00000000..4fb07f40 --- /dev/null +++ b/test/game/components/scoring_behavior_test.dart @@ -0,0 +1,112 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.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_audio/pinball_audio.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +class _TestBodyComponent extends BodyComponent { + @override + Body createBody() => world.createBody(BodyDef()); +} + +void main() { + group('ScoringBehavior', () { + group('beginContact', () { + late GameBloc bloc; + late PinballAudio audio; + late Ball ball; + late BodyComponent parent; + + setUp(() { + audio = MockPinballAudio(); + + ball = MockBall(); + final ballBody = MockBody(); + when(() => ball.body).thenReturn(ballBody); + when(() => ballBody.position).thenReturn(Vector2.all(4)); + + parent = _TestBodyComponent(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: () => EmptyPinballTestGame( + audio: audio, + ), + blocBuilder: () { + bloc = MockGameBloc(); + const state = GameState( + score: 0, + multiplier: 1, + rounds: 3, + bonusHistory: [], + ); + whenListen(bloc, Stream.value(state), initialState: state); + return bloc; + }, + ); + + flameBlocTester.testGameWidget( + 'emits Scored event with points', + setUp: (game, tester) async { + const points = 20; + final scoringBehavior = ScoringBehavior(points: points); + await parent.add(scoringBehavior); + await game.ensureAdd(parent); + + scoringBehavior.beginContact(ball, MockContact()); + + verify( + () => bloc.add( + const Scored(points: points), + ), + ).called(1); + }, + ); + + flameBlocTester.testGameWidget( + 'plays score sound', + setUp: (game, tester) async { + const points = 20; + final scoringBehavior = ScoringBehavior(points: points); + await parent.add(scoringBehavior); + await game.ensureAdd(parent); + + scoringBehavior.beginContact(ball, MockContact()); + + verify(audio.score).called(1); + }, + ); + + flameBlocTester.testGameWidget( + "adds a ScoreText component at Ball's position with points", + setUp: (game, tester) async { + const points = 20; + final scoringBehavior = ScoringBehavior(points: points); + await parent.add(scoringBehavior); + await game.ensureAdd(parent); + + scoringBehavior.beginContact(ball, MockContact()); + await game.ready(); + + final scoreText = game.descendants().whereType(); + expect(scoreText.length, equals(1)); + expect( + scoreText.first.text, + equals(points.toString()), + ); + expect( + scoreText.first.position, + equals(ball.body.position), + ); + }, + ); + }); + }); +} diff --git a/test/game/components/sparky_fire_zone_test.dart b/test/game/components/sparky_fire_zone_test.dart index 0ad69dab..9b254617 100644 --- a/test/game/components/sparky_fire_zone_test.dart +++ b/test/game/components/sparky_fire_zone_test.dart @@ -1,8 +1,5 @@ // ignore_for_file: cascade_invocations -import 'dart:ui'; - -import 'package:bloc_test/bloc_test.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -23,7 +20,10 @@ void main() { Assets.images.sparky.bumper.c.inactive.keyName, Assets.images.sparky.animatronic.keyName, ]; - final flameTester = FlameTester(() => EmptyPinballTestGame(assets)); + + final flameTester = FlameTester( + () => EmptyPinballTestGame(assets: assets), + ); group('SparkyFireZone', () { flameTester.test('loads correctly', (game) async { @@ -70,93 +70,40 @@ void main() { }, ); }); - - group('bumpers', () { - late GameBloc gameBloc; - - setUp(() { - gameBloc = MockGameBloc(); - whenListen( - gameBloc, - const Stream.empty(), - initialState: const GameState.initial(), - ); - }); - - final flameBlocTester = FlameBlocTester( - gameBuilder: EmptyPinballTestGame.new, - blocBuilder: () => gameBloc, - assets: assets, - ); - - flameTester.test('call animate on contact', (game) async { - final contactCallback = SparkyBumperBallContactCallback(); - final bumper = MockSparkyBumper(); - final ball = MockBall(); - - when(bumper.animate).thenAnswer((_) async {}); - - contactCallback.begin(bumper, ball, MockContact()); - - verify(bumper.animate).called(1); - }); - - flameBlocTester.testGameWidget( - 'add Scored event', - setUp: (game, tester) async { - final ball = Ball(baseColor: const Color(0xFF00FFFF)); - final sparkyFireZone = SparkyFireZone(); - await game.addFromBlueprint(sparkyFireZone); - await game.ensureAdd(ball); - game.addContactCallback(BallScorePointsCallback(game)); - - final bumpers = sparkyFireZone.components.whereType(); - - for (final bumper in bumpers) { - beginContact(game, bumper, ball); - verify( - () => gameBloc.add( - Scored(points: bumper.points), - ), - ).called(1); - } - }, - ); - }); }); - group('SparkyTurboChargeSensorBallContactCallback', () { + group('SparkyComputerSensor', () { flameTester.test('calls turboCharge', (game) async { - final callback = SparkyComputerSensorBallContactCallback(); + final sensor = SparkyComputerSensor(); final ball = MockControlledBall(); final controller = MockBallController(); when(() => ball.controller).thenReturn(controller); - when(() => ball.gameRef).thenReturn(game); when(controller.turboCharge).thenAnswer((_) async {}); - callback.begin(MockSparkyComputerSensor(), ball, MockContact()); + await game.ensureAddAll([ + sensor, + SparkyAnimatronic(), + ]); + + sensor.beginContact(ball, MockContact()); verify(() => ball.controller.turboCharge()).called(1); }); flameTester.test('plays SparkyAnimatronic', (game) async { - final callback = SparkyComputerSensorBallContactCallback(); + final sensor = SparkyComputerSensor(); + final sparkyAnimatronic = SparkyAnimatronic(); final ball = MockControlledBall(); final controller = MockBallController(); when(() => ball.controller).thenReturn(controller); - when(() => ball.gameRef).thenReturn(game); when(controller.turboCharge).thenAnswer((_) async {}); - - final sparkyFireZone = SparkyFireZone(); - await game.addFromBlueprint(sparkyFireZone); - await game.ready(); - - final sparkyAnimatronic = - sparkyFireZone.components.whereType().single; + await game.ensureAddAll([ + sensor, + sparkyAnimatronic, + ]); expect(sparkyAnimatronic.playing, isFalse); - callback.begin(MockSparkyComputerSensor(), ball, MockContact()); - + sensor.beginContact(ball, MockContact()); expect(sparkyAnimatronic.playing, isTrue); }); }); diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart index 63a39991..16f7ce34 100644 --- a/test/game/components/wall_test.dart +++ b/test/game/components/wall_test.dart @@ -117,10 +117,11 @@ void main() { flameBlocTester.testGameWidget( 'when ball is launch', setUp: (game, tester) async { - final ball = ControlledBall.launch(theme: game.theme); + final ball = ControlledBall.launch( + characterTheme: game.characterTheme, + ); final wall = BottomWall(); await game.ensureAddAll([ball, wall]); - game.addContactCallback(BottomWallBallContactCallback()); beginContact(game, ball, wall); await game.ready(); @@ -132,10 +133,11 @@ void main() { flameBlocTester.testGameWidget( 'when ball is bonus', setUp: (game, tester) async { - final ball = ControlledBall.bonus(theme: game.theme); + final ball = ControlledBall.bonus( + characterTheme: game.characterTheme, + ); final wall = BottomWall(); await game.ensureAddAll([ball, wall]); - game.addContactCallback(BottomWallBallContactCallback()); beginContact(game, ball, wall); await game.ready(); @@ -144,13 +146,12 @@ void main() { }, ); - flameTester.test( + flameBlocTester.testGameWidget( 'when ball is debug', - (game) async { + setUp: (game, tester) async { final ball = ControlledBall.debug(); final wall = BottomWall(); await game.ensureAddAll([ball, wall]); - game.addContactCallback(BottomWallBallContactCallback()); beginContact(game, ball, wall); await game.ready(); diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index f5d27b31..2fdbe6c4 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -13,28 +13,54 @@ import '../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); final assets = [ - Assets.images.dash.bumper.main.active.keyName, - Assets.images.dash.bumper.main.inactive.keyName, + Assets.images.androidBumper.a.lit.keyName, + Assets.images.androidBumper.a.dimmed.keyName, + Assets.images.androidBumper.b.lit.keyName, + Assets.images.androidBumper.b.dimmed.keyName, + Assets.images.backboard.backboardScores.keyName, + Assets.images.backboard.backboardGameOver.keyName, + Assets.images.backboard.display.keyName, + Assets.images.ball.ball.keyName, + Assets.images.ball.flameEffect.keyName, + Assets.images.baseboard.left.keyName, + Assets.images.baseboard.right.keyName, + Assets.images.boundary.bottom.keyName, + Assets.images.boundary.outer.keyName, + Assets.images.boundary.outerBottom.keyName, + Assets.images.dino.animatronic.mouth.keyName, + Assets.images.dino.animatronic.head.keyName, + Assets.images.dino.topWall.keyName, + Assets.images.dino.bottomWall.keyName, + Assets.images.dash.animatronic.keyName, Assets.images.dash.bumper.a.active.keyName, Assets.images.dash.bumper.a.inactive.keyName, Assets.images.dash.bumper.b.active.keyName, Assets.images.dash.bumper.b.inactive.keyName, - Assets.images.dash.animatronic.keyName, + Assets.images.dash.bumper.main.active.keyName, + Assets.images.dash.bumper.main.inactive.keyName, + Assets.images.flipper.left.keyName, + Assets.images.flipper.right.keyName, + Assets.images.googleWord.letter1.keyName, + Assets.images.googleWord.letter2.keyName, + Assets.images.googleWord.letter3.keyName, + Assets.images.googleWord.letter4.keyName, + Assets.images.googleWord.letter5.keyName, + Assets.images.googleWord.letter6.keyName, + Assets.images.kicker.left.keyName, + Assets.images.kicker.right.keyName, + Assets.images.launchRamp.ramp.keyName, + Assets.images.launchRamp.foregroundRailing.keyName, + Assets.images.launchRamp.backgroundRailing.keyName, + Assets.images.plunger.plunger.keyName, + Assets.images.plunger.rocket.keyName, Assets.images.signpost.inactive.keyName, Assets.images.signpost.active1.keyName, Assets.images.signpost.active2.keyName, Assets.images.signpost.active3.keyName, - Assets.images.alienBumper.a.active.keyName, - Assets.images.alienBumper.a.inactive.keyName, - Assets.images.alienBumper.b.active.keyName, - Assets.images.alienBumper.b.inactive.keyName, - Assets.images.sparky.bumper.a.active.keyName, - Assets.images.sparky.bumper.a.inactive.keyName, - Assets.images.sparky.bumper.b.active.keyName, - Assets.images.sparky.bumper.b.inactive.keyName, - Assets.images.sparky.bumper.c.active.keyName, - Assets.images.sparky.bumper.c.inactive.keyName, - Assets.images.sparky.animatronic.keyName, + Assets.images.slingshot.upper.keyName, + Assets.images.slingshot.lower.keyName, + Assets.images.spaceship.saucer.keyName, + Assets.images.spaceship.bridge.keyName, Assets.images.spaceship.ramp.boardOpening.keyName, Assets.images.spaceship.ramp.railingForeground.keyName, Assets.images.spaceship.ramp.railingBackground.keyName, @@ -45,33 +71,41 @@ void main() { Assets.images.spaceship.ramp.arrow.active3.keyName, Assets.images.spaceship.ramp.arrow.active4.keyName, Assets.images.spaceship.ramp.arrow.active5.keyName, - Assets.images.dino.animatronic.head.keyName, - Assets.images.dino.animatronic.mouth.keyName, - Assets.images.baseboard.left.keyName, - Assets.images.baseboard.right.keyName, - Assets.images.flipper.left.keyName, - Assets.images.flipper.right.keyName, - Assets.images.boundary.outer.keyName, - Assets.images.boundary.outerBottom.keyName, - Assets.images.boundary.bottom.keyName, - Assets.images.slingshot.upper.keyName, - Assets.images.slingshot.lower.keyName, - Assets.images.dino.topWall.keyName, - Assets.images.dino.bottomWall.keyName, + Assets.images.spaceship.rail.main.keyName, + Assets.images.spaceship.rail.exit.keyName, + Assets.images.sparky.bumper.a.active.keyName, + Assets.images.sparky.bumper.a.inactive.keyName, + Assets.images.sparky.bumper.b.active.keyName, + Assets.images.sparky.bumper.b.inactive.keyName, + Assets.images.sparky.bumper.c.active.keyName, + Assets.images.sparky.bumper.c.inactive.keyName, + Assets.images.sparky.animatronic.keyName, + Assets.images.sparky.computer.top.keyName, + Assets.images.sparky.computer.base.keyName, + Assets.images.sparky.animatronic.keyName, + Assets.images.sparky.bumper.a.inactive.keyName, + Assets.images.sparky.bumper.a.active.keyName, + Assets.images.sparky.bumper.b.active.keyName, + Assets.images.sparky.bumper.b.inactive.keyName, + Assets.images.sparky.bumper.c.active.keyName, + Assets.images.sparky.bumper.c.inactive.keyName, ]; - final flameTester = FlameTester(() => PinballTestGame(assets)); - final debugModeFlameTester = FlameTester(() => DebugPinballTestGame(assets)); + + final flameTester = FlameTester( + () => PinballTestGame(assets: assets), + ); + final debugModeFlameTester = FlameTester( + () => DebugPinballTestGame(assets: assets), + ); group('PinballGame', () { - // TODO(alestiago): test if [PinballGame] registers - // [BallScorePointsCallback] once the following issue is resolved: - // https://github.com/flame-engine/flame/issues/1416 group('components', () { + // TODO(alestiago): tests that Blueprints get added once the Blueprint + // class is removed. flameTester.test( 'has only one BottomWall', (game) async { await game.ready(); - expect( game.children.whereType().length, equals(1), @@ -90,42 +124,34 @@ void main() { }, ); - flameTester.test('has one Board', (game) async { - await game.ready(); - expect( - game.children.whereType().length, - equals(1), - ); - }); + flameTester.test( + 'has one Board', + (game) async { + await game.ready(); + expect( + game.children.whereType().length, + equals(1), + ); + }, + ); flameTester.test( - 'one AlienZone', + 'one GoogleWord', (game) async { await game.ready(); - expect(game.children.whereType().length, equals(1)); + expect(game.children.whereType().length, equals(1)); }, ); group('controller', () { - // TODO(alestiago): Write test to be controller agnostic. group('listenWhen', () { - late GameBloc gameBloc; - - setUp(() { - gameBloc = GameBloc(); - }); - - final flameBlocTester = FlameBlocTester( - gameBuilder: EmptyPinballTestGame.new, - blocBuilder: () => gameBloc, - // assets: assets, - ); - - flameBlocTester.testGameWidget( - 'listens when all balls are lost and there are more than 0 balls', + flameTester.testGameWidget( + 'listens when all balls are lost and there are more than 0 rounds', setUp: (game, tester) async { + // TODO(ruimiguel): check why testGameWidget doesn't add any ball + // to the game. Test needs to have no balls, so fortunately works. final newState = MockGameState(); - when(() => newState.balls).thenReturn(2); + when(() => newState.isGameOver).thenReturn(false); game.descendants().whereType().forEach( (ball) => ball.controller.lost(), ); @@ -142,10 +168,10 @@ void main() { "doesn't listen when some balls are left", (game) async { final newState = MockGameState(); - when(() => newState.balls).thenReturn(1); + when(() => newState.isGameOver).thenReturn(false); expect( - game.descendants().whereType().length, + game.descendants().whereType().length, greaterThan(0), ); expect( @@ -155,19 +181,20 @@ void main() { }, ); - flameBlocTester.test( - "doesn't listen when no balls left", - (game) async { + flameTester.testGameWidget( + "doesn't listen when game is over", + setUp: (game, tester) async { + // TODO(ruimiguel): check why testGameWidget doesn't add any ball + // to the game. Test needs to have no balls, so fortunately works. final newState = MockGameState(); - when(() => newState.balls).thenReturn(0); - + when(() => newState.isGameOver).thenReturn(true); game.descendants().whereType().forEach( (ball) => ball.controller.lost(), ); await game.ready(); expect( - game.descendants().whereType().isEmpty, + game.descendants().whereType().isEmpty, isTrue, ); expect( @@ -184,14 +211,13 @@ void main() { flameTester.test( 'spawns a ball', (game) async { - await game.ready(); final previousBalls = - game.descendants().whereType().toList(); + game.descendants().whereType().toList(); game.controller.onNewState(MockGameState()); await game.ready(); final currentBalls = - game.descendants().whereType().toList(); + game.descendants().whereType().toList(); expect( currentBalls.length, @@ -206,57 +232,26 @@ void main() { }); group('DebugPinballGame', () { - debugModeFlameTester.test('adds a ball on tap up', (game) async { - await game.ready(); - - final eventPosition = MockEventPosition(); - when(() => eventPosition.game).thenReturn(Vector2.all(10)); - - final tapUpEvent = MockTapUpInfo(); - when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); - - final previousBalls = game.descendants().whereType().toList(); - - game.onTapUp(tapUpEvent); - await game.ready(); - - expect( - game.children.whereType().length, - equals(previousBalls.length + 1), - ); - }); - - group('controller', () { - late GameBloc gameBloc; - - setUp(() { - gameBloc = GameBloc(); - }); + debugModeFlameTester.test( + 'adds a ball on tap up', + (game) async { + final eventPosition = MockEventPosition(); + when(() => eventPosition.game).thenReturn(Vector2.all(10)); - final debugModeFlameBlocTester = - FlameBlocTester( - gameBuilder: DebugPinballTestGame.new, - blocBuilder: () => gameBloc, - assets: assets, - ); + final tapUpEvent = MockTapUpInfo(); + when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); - debugModeFlameBlocTester.testGameWidget( - 'ignores debug balls', - setUp: (game, tester) async { - final newState = MockGameState(); - when(() => newState.balls).thenReturn(1); + final previousBalls = + game.descendants().whereType().toList(); - await game.ready(); - game.children.removeWhere((component) => component is Ball); - await game.ready(); - await game.ensureAdd(ControlledBall.debug()); + game.onTapUp(tapUpEvent); + await game.ready(); - expect( - game.controller.listenWhen(MockGameState(), newState), - isTrue, - ); - }, - ); - }); + expect( + game.children.whereType().length, + equals(previousBalls.length + 1), + ); + }, + ); }); } diff --git a/test/game/view/game_hud_test.dart b/test/game/view/game_hud_test.dart deleted file mode 100644 index cdc56832..00000000 --- a/test/game/view/game_hud_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball/game/game.dart'; -import '../../helpers/helpers.dart'; - -void main() { - group('GameHud', () { - late GameBloc gameBloc; - const initialState = GameState( - score: 10, - balls: 2, - bonusHistory: [], - ); - - void _mockState(GameState state) { - whenListen( - gameBloc, - Stream.value(state), - initialState: state, - ); - } - - Future _pumpHud(WidgetTester tester) async { - await tester.pumpApp( - GameHud(), - gameBloc: gameBloc, - ); - } - - setUp(() { - gameBloc = MockGameBloc(); - _mockState(initialState); - }); - - testWidgets( - 'renders the current score', - (tester) async { - await _pumpHud(tester); - expect(find.text(initialState.score.toString()), findsOneWidget); - }, - ); - - testWidgets( - 'renders the current ball number', - (tester) async { - await _pumpHud(tester); - expect( - find.byType(CircleAvatar), - findsNWidgets(initialState.balls), - ); - }, - ); - - testWidgets('updates the score', (tester) async { - await _pumpHud(tester); - expect(find.text(initialState.score.toString()), findsOneWidget); - - _mockState(initialState.copyWith(score: 20)); - - await tester.pump(); - expect(find.text('20'), findsOneWidget); - }); - - testWidgets('updates the ball number', (tester) async { - await _pumpHud(tester); - expect( - find.byType(CircleAvatar), - findsNWidgets(initialState.balls), - ); - - _mockState(initialState.copyWith(balls: 1)); - - await tester.pump(); - expect( - find.byType(CircleAvatar), - findsNWidgets(1), - ); - }); - }); -} diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index bbed2963..f8b62d05 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -5,7 +5,8 @@ import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import '../../helpers/helpers.dart'; @@ -13,18 +14,18 @@ void main() { final game = PinballTestGame(); group('PinballGamePage', () { - late ThemeCubit themeCubit; + late CharacterThemeCubit characterThemeCubit; late GameBloc gameBloc; setUp(() async { await Future.wait(game.preLoadAssets()); - themeCubit = MockThemeCubit(); + characterThemeCubit = MockCharacterThemeCubit(); gameBloc = MockGameBloc(); whenListen( - themeCubit, - const Stream.empty(), - initialState: const ThemeState.initial(), + characterThemeCubit, + const Stream.empty(), + initialState: const CharacterThemeState.initial(), ); whenListen( @@ -37,7 +38,7 @@ void main() { testWidgets('renders PinballGameView', (tester) async { await tester.pumpApp( PinballGamePage(), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); expect(find.byType(PinballGameView), findsOneWidget); @@ -62,7 +63,7 @@ void main() { game: game, ), assetsManagerCubit: assetsManagerCubit, - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); expect( @@ -79,6 +80,7 @@ void main() { 'renders PinballGameLoadedView after resources have been loaded', (tester) async { final assetsManagerCubit = MockAssetsManagerCubit(); + final startGameBloc = MockStartGameBloc(); final loadedAssetsState = AssetsManagerState( loadables: [Future.value()], @@ -89,14 +91,20 @@ void main() { Stream.value(loadedAssetsState), initialState: loadedAssetsState, ); + whenListen( + startGameBloc, + Stream.value(StartGameState.initial()), + initialState: StartGameState.initial(), + ); await tester.pumpApp( PinballGameView( game: game, ), assetsManagerCubit: assetsManagerCubit, - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, gameBloc: gameBloc, + startGameBloc: startGameBloc, ); await tester.pump(); @@ -126,7 +134,7 @@ void main() { }, ), ), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); await tester.tap(find.text('Tap me')); @@ -160,27 +168,61 @@ void main() { }); group('PinballGameView', () { + final gameBloc = MockGameBloc(); + final startGameBloc = MockStartGameBloc(); + setUp(() async { await Future.wait(game.preLoadAssets()); - }); - testWidgets('renders game and a hud', (tester) async { - final gameBloc = MockGameBloc(); whenListen( gameBloc, Stream.value(const GameState.initial()), initialState: const GameState.initial(), ); + whenListen( + startGameBloc, + Stream.value(StartGameState.initial()), + initialState: StartGameState.initial(), + ); + }); + + testWidgets('renders game', (tester) async { await tester.pumpApp( PinballGameView(game: game), gameBloc: gameBloc, + startGameBloc: startGameBloc, ); expect( find.byWidgetPredicate((w) => w is GameWidget), findsOneWidget, ); + // TODO(arturplaczek): add Visibility to GameHud based on StartGameBloc + // status + // expect( + // find.byType(GameHud), + // findsNothing, + // ); + }); + + testWidgets('renders a hud on play state', (tester) async { + final startGameState = StartGameState.initial().copyWith( + status: StartGameStatus.play, + ); + + whenListen( + startGameBloc, + Stream.value(startGameState), + initialState: startGameState, + ); + + await tester.pumpApp( + PinballGameView(game: game), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + expect( find.byType(GameHud), findsOneWidget, diff --git a/test/game/view/widgets/bonus_animation_test.dart b/test/game/view/widgets/bonus_animation_test.dart index 9c23ae0d..11e249c7 100644 --- a/test/game/view/widgets/bonus_animation_test.dart +++ b/test/game/view/widgets/bonus_animation_test.dart @@ -1,13 +1,16 @@ -import 'dart:async'; +// ignore_for_file: invalid_use_of_protected_member +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flame/assets.dart'; -import 'package:flame/widgets.dart'; +import 'package:flame/flame.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; import 'package:pinball/game/view/widgets/bonus_animation.dart'; +import 'package:pinball_flame/pinball_flame.dart'; import '../../../helpers/helpers.dart'; @@ -15,11 +18,21 @@ class MockImages extends Mock implements Images {} class MockImage extends Mock implements ui.Image {} +class MockCallback extends Mock { + void call(); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { - await BonusAnimation.loadAssets(); + // TODO(arturplaczek): need to find for a better solution for loading image + // or use original images from BonusAnimation.loadAssets() + final image = await decodeImageFromList(Uint8List.fromList(fakeImage)); + final images = MockImages(); + when(() => images.fromCache(any())).thenReturn(image); + when(() => images.load(any())).thenAnswer((_) => Future.value(image)); + Flame.images = images; }); group('loads SpriteAnimationWidget correctly for', () { @@ -32,9 +45,9 @@ void main() { expect(find.byType(SpriteAnimationWidget), findsOneWidget); }); - testWidgets('dino', (tester) async { + testWidgets('dinoChomp', (tester) async { await tester.pumpApp( - BonusAnimation.dino(), + BonusAnimation.dinoChomp(), ); await tester.pump(); @@ -50,18 +63,18 @@ void main() { expect(find.byType(SpriteAnimationWidget), findsOneWidget); }); - testWidgets('google', (tester) async { + testWidgets('googleWord', (tester) async { await tester.pumpApp( - BonusAnimation.google(), + BonusAnimation.googleWord(), ); await tester.pump(); expect(find.byType(SpriteAnimationWidget), findsOneWidget); }); - testWidgets('android', (tester) async { + testWidgets('androidSpaceship', (tester) async { await tester.pumpApp( - BonusAnimation.android(), + BonusAnimation.androidSpaceship(), ); await tester.pump(); @@ -74,14 +87,14 @@ void main() { // https://github.com/flame-engine/flame/issues/1543 testWidgets('called onCompleted callback at the end of animation ', (tester) async { - final completer = Completer(); + final callback = MockCallback(); await tester.runAsync(() async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: BonusAnimation.dashNest( - onCompleted: completer.complete, + onCompleted: callback.call, ), ), ), @@ -93,7 +106,38 @@ void main() { await tester.pump(); - expect(completer.isCompleted, isTrue); + verify(callback.call).called(1); + }); + }); + + testWidgets('called onCompleted once when animation changed', (tester) async { + final callback = MockCallback(); + final secondAnimation = BonusAnimation.sparkyTurboCharge( + onCompleted: callback.call, + ); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BonusAnimation.dashNest( + onCompleted: callback.call, + ), + ), + ), + ); + + await tester.pump(); + + tester + .state(find.byType(BonusAnimation)) + .didUpdateWidget(secondAnimation); + + await Future.delayed(const Duration(seconds: 4)); + + await tester.pump(); + + verify(callback.call).called(1); }); }); } diff --git a/test/game/view/widgets/game_hud_test.dart b/test/game/view/widgets/game_hud_test.dart new file mode 100644 index 00000000..d101d06e --- /dev/null +++ b/test/game/view/widgets/game_hud_test.dart @@ -0,0 +1,155 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/assets.dart'; +import 'package:flame/flame.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_components/pinball_components.dart' hide Assets; + +import '../../../helpers/helpers.dart'; + +class MockImages extends Mock implements Images {} + +class MockImage extends Mock implements ui.Image {} + +void main() { + group('GameHud', () { + late GameBloc gameBloc; + + const initialState = GameState( + score: 1000, + multiplier: 1, + rounds: 1, + bonusHistory: [], + ); + + setUp(() async { + gameBloc = MockGameBloc(); + + // TODO(arturplaczek): need to find for a better solution for loading + // image or use original images from BonusAnimation.loadAssets() + final image = await decodeImageFromList(Uint8List.fromList(fakeImage)); + final images = MockImages(); + when(() => images.fromCache(any())).thenReturn(image); + when(() => images.load(any())).thenAnswer((_) => Future.value(image)); + Flame.images = images; + + whenListen( + gameBloc, + Stream.value(initialState), + initialState: initialState, + ); + }); + + // We cannot use pumpApp when we are testing animation because + // animation tests needs to be run and check in tester.runAsync + Future _pumpAppWithWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BlocProvider.value( + value: gameBloc, + child: GameHud(), + ), + ), + ), + ); + } + + group('renders ScoreView widget', () { + testWidgets( + 'with the score', + (tester) async { + await tester.pumpApp( + GameHud(), + gameBloc: gameBloc, + ); + + expect(find.text(initialState.score.formatScore()), findsOneWidget); + }, + ); + + testWidgets( + 'on game over', + (tester) async { + final state = initialState.copyWith( + bonusHistory: [GameBonus.dashNest], + balls: 0, + ); + + whenListen( + gameBloc, + Stream.value(state), + initialState: initialState, + ); + await tester.pumpApp( + GameHud(), + gameBloc: gameBloc, + ); + + expect(find.byType(ScoreView), findsOneWidget); + expect(find.byType(BonusAnimation), findsNothing); + }, + ); + }); + + for (final gameBonus in GameBonus.values) { + testWidgets('renders BonusAnimation for $gameBonus', (tester) async { + await tester.runAsync(() async { + final state = initialState.copyWith( + bonusHistory: [gameBonus], + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: initialState, + ); + + await _pumpAppWithWidget(tester); + await tester.pump(); + + expect(find.byType(BonusAnimation), findsOneWidget); + }); + }); + } + + testWidgets( + 'goes back to ScoreView after the animation', + (tester) async { + await tester.runAsync(() async { + final state = initialState.copyWith( + bonusHistory: [GameBonus.dashNest], + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: initialState, + ); + + await _pumpAppWithWidget(tester); + await tester.pump(); + // TODO(arturplaczek): remove magic number once this is merged: + // https://github.com/flame-engine/flame/pull/1564 + await Future.delayed(const Duration(seconds: 4)); + + await expectLater(find.byType(ScoreView), findsOneWidget); + }); + }, + ); + }); +} diff --git a/test/game/view/widgets/play_button_overlay_test.dart b/test/game/view/widgets/play_button_overlay_test.dart index 210cc347..0345978d 100644 --- a/test/game/view/widgets/play_button_overlay_test.dart +++ b/test/game/view/widgets/play_button_overlay_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; import '../../../helpers/helpers.dart'; diff --git a/test/game/view/widgets/round_count_display_test.dart b/test/game/view/widgets/round_count_display_test.dart new file mode 100644 index 00000000..dfa28869 --- /dev/null +++ b/test/game/view/widgets/round_count_display_test.dart @@ -0,0 +1,133 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/theme/app_colors.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + group('RoundCountDisplay renders', () { + late GameBloc gameBloc; + const initialState = GameState( + score: 0, + multiplier: 1, + rounds: 3, + bonusHistory: [], + ); + + setUp(() { + gameBloc = MockGameBloc(); + + whenListen( + gameBloc, + Stream.value(initialState), + initialState: initialState, + ); + }); + + testWidgets('three active round indicator', (tester) async { + await tester.pumpApp( + const RoundCountDisplay(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect(find.byType(RoundIndicator), findsNWidgets(3)); + }); + + testWidgets('two active round indicator', (tester) async { + final state = initialState.copyWith( + rounds: 2, + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + + await tester.pumpApp( + const RoundCountDisplay(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && widget.isActive, + ), + findsNWidgets(2), + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && !widget.isActive, + ), + findsOneWidget, + ); + }); + + testWidgets('one active round indicator', (tester) async { + final state = initialState.copyWith( + rounds: 1, + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + + await tester.pumpApp( + const RoundCountDisplay(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && widget.isActive, + ), + findsOneWidget, + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is RoundIndicator && !widget.isActive, + ), + findsNWidgets(2), + ); + }); + }); + + testWidgets('active round indicator is displaying with proper color', + (tester) async { + await tester.pumpApp( + const RoundIndicator(isActive: true), + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => widget is Container && widget.color == AppColors.orange, + ), + findsOneWidget, + ); + }); + + testWidgets('inactive round indicator is displaying with proper color', + (tester) async { + await tester.pumpApp( + const RoundIndicator(isActive: false), + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is Container && + widget.color == AppColors.orange.withAlpha(128), + ), + findsOneWidget, + ); + }); +} diff --git a/test/game/view/widgets/score_view_test.dart b/test/game/view/widgets/score_view_test.dart new file mode 100644 index 00000000..63f7d1c5 --- /dev/null +++ b/test/game/view/widgets/score_view_test.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + late GameBloc gameBloc; + late StreamController stateController; + const score = 123456789; + const initialState = GameState( + score: score, + multiplier: 1, + rounds: 1, + bonusHistory: [], + ); + + setUp(() { + gameBloc = MockGameBloc(); + stateController = StreamController()..add(initialState); + + whenListen( + gameBloc, + stateController.stream, + initialState: initialState, + ); + }); + + group('ScoreView', () { + testWidgets('renders score', (tester) async { + await tester.pumpApp( + const ScoreView(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect(find.text(score.formatScore()), findsOneWidget); + }); + + testWidgets('renders game over', (tester) async { + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + stateController.add( + initialState.copyWith( + rounds: 0, + ), + ); + + await tester.pumpApp( + const ScoreView(), + gameBloc: gameBloc, + ); + await tester.pump(); + + expect(find.text(l10n.gameOver), findsOneWidget); + }); + + testWidgets('updates the score', (tester) async { + await tester.pumpApp( + const ScoreView(), + gameBloc: gameBloc, + ); + + expect(find.text(score.formatScore()), findsOneWidget); + + final newState = initialState.copyWith( + score: 987654321, + ); + + stateController.add(newState); + + await tester.pump(); + + expect(find.text(newState.score.formatScore()), findsOneWidget); + }); + }); +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 706733a1..d782ede4 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -5,3 +5,70 @@ import 'package:pinball/game/game.dart'; class FakeContact extends Fake implements Contact {} class FakeGameEvent extends Fake implements GameEvent {} + +const fakeImage = [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, +]; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 9b0f67c9..b58dc619 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -8,7 +8,8 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/leaderboard/leaderboard.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -28,14 +29,13 @@ class MockBallController extends Mock implements BallController {} class MockContact extends Mock implements Contact {} -class MockContactCallback extends Mock - implements ContactCallback {} - class MockGameBloc extends Mock implements GameBloc {} +class MockStartGameBloc extends Mock implements StartGameBloc {} + class MockGameState extends Mock implements GameState {} -class MockThemeCubit extends Mock implements ThemeCubit {} +class MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {} class MockLeaderboardBloc extends Mock implements LeaderboardBloc {} @@ -82,6 +82,6 @@ class MockActiveOverlaysNotifier extends Mock class MockGameFlowController extends Mock implements GameFlowController {} -class MockAlienBumper extends Mock implements AlienBumper {} +class MockAndroidBumper extends Mock implements AndroidBumper {} class MockSparkyBumper extends Mock implements SparkyBumper {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index 92e2c042..2c112426 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -14,7 +14,8 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'helpers.dart'; @@ -51,8 +52,9 @@ extension PumpApp on WidgetTester { Widget widget, { MockNavigator? navigator, GameBloc? gameBloc, + StartGameBloc? startGameBloc, AssetsManagerCubit? assetsManagerCubit, - ThemeCubit? themeCubit, + CharacterThemeCubit? characterThemeCubit, LeaderboardRepository? leaderboardRepository, PinballAudio? pinballAudio, }) { @@ -70,11 +72,14 @@ extension PumpApp on WidgetTester { child: MultiBlocProvider( providers: [ BlocProvider.value( - value: themeCubit ?? MockThemeCubit(), + value: characterThemeCubit ?? MockCharacterThemeCubit(), ), BlocProvider.value( value: gameBloc ?? MockGameBloc(), ), + BlocProvider.value( + value: startGameBloc ?? MockStartGameBloc(), + ), BlocProvider.value( value: assetsManagerCubit ?? _buildDefaultAssetsManagerCubit(), ), diff --git a/test/helpers/test_games.dart b/test/helpers/test_games.dart index 10caa768..baa466b8 100644 --- a/test/helpers/test_games.dart +++ b/test/helpers/test_games.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_theme/pinball_theme.dart'; import 'helpers.dart'; @@ -16,13 +17,14 @@ class TestGame extends Forge2DGame with FlameBloc { } class PinballTestGame extends PinballGame { - PinballTestGame([List? assets]) - : _assets = assets, + PinballTestGame({ + List? assets, + PinballAudio? audio, + CharacterTheme? theme, + }) : _assets = assets, super( - audio: MockPinballAudio(), - theme: const PinballTheme( - characterTheme: DashTheme(), - ), + audio: audio ?? MockPinballAudio(), + characterTheme: theme ?? const DashTheme(), ); final List? _assets; @@ -36,13 +38,14 @@ class PinballTestGame extends PinballGame { } class DebugPinballTestGame extends DebugPinballGame { - DebugPinballTestGame([List? assets]) - : _assets = assets, + DebugPinballTestGame({ + List? assets, + PinballAudio? audio, + CharacterTheme? theme, + }) : _assets = assets, super( - audio: MockPinballAudio(), - theme: const PinballTheme( - characterTheme: DashTheme(), - ), + audio: audio ?? MockPinballAudio(), + characterTheme: theme ?? const DashTheme(), ); final List? _assets; @@ -57,7 +60,15 @@ class DebugPinballTestGame extends DebugPinballGame { } class EmptyPinballTestGame extends PinballTestGame { - EmptyPinballTestGame([List? assets]) : super(assets); + EmptyPinballTestGame({ + List? assets, + PinballAudio? audio, + CharacterTheme? theme, + }) : super( + assets: assets, + audio: audio, + theme: theme, + ); @override Future onLoad() async { diff --git a/test/select_character/cubit/character_theme_cubit_test.dart b/test/select_character/cubit/character_theme_cubit_test.dart new file mode 100644 index 00000000..967eb1e1 --- /dev/null +++ b/test/select_character/cubit/character_theme_cubit_test.dart @@ -0,0 +1,25 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('CharacterThemeCubit', () { + test('initial state has Dash character theme', () { + final characterThemeCubit = CharacterThemeCubit(); + expect( + characterThemeCubit.state.characterTheme, + equals(const DashTheme()), + ); + }); + + blocTest( + 'charcterSelected emits selected character theme', + build: CharacterThemeCubit.new, + act: (bloc) => bloc.characterSelected(const SparkyTheme()), + expect: () => [ + const CharacterThemeState(SparkyTheme()), + ], + ); + }); +} diff --git a/test/theme/cubit/theme_state_test.dart b/test/select_character/cubit/character_theme_state_test.dart similarity index 54% rename from test/theme/cubit/theme_state_test.dart rename to test/select_character/cubit/character_theme_state_test.dart index 49a2a387..c0d584e2 100644 --- a/test/theme/cubit/theme_state_test.dart +++ b/test/select_character/cubit/character_theme_state_test.dart @@ -1,18 +1,18 @@ // ignore_for_file: prefer_const_constructors import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball/theme/theme.dart'; +import 'package:pinball/select_character/select_character.dart'; void main() { group('ThemeState', () { test('can be instantiated', () { - expect(const ThemeState.initial(), isNotNull); + expect(const CharacterThemeState.initial(), isNotNull); }); test('supports value equality', () { expect( - ThemeState.initial(), - equals(const ThemeState.initial()), + CharacterThemeState.initial(), + equals(const CharacterThemeState.initial()), ); }); }); diff --git a/test/theme/view/character_selection_page_test.dart b/test/select_character/view/character_selection_page_test.dart similarity index 79% rename from test/theme/view/character_selection_page_test.dart rename to test/select_character/view/character_selection_page_test.dart index dcf54a13..0dda92d7 100644 --- a/test/theme/view/character_selection_page_test.dart +++ b/test/select_character/view/character_selection_page_test.dart @@ -4,21 +4,21 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/start_game/start_game.dart'; -import 'package:pinball/theme/theme.dart'; import 'package:pinball_theme/pinball_theme.dart'; import '../../helpers/helpers.dart'; void main() { - late ThemeCubit themeCubit; + late CharacterThemeCubit characterThemeCubit; setUp(() { - themeCubit = MockThemeCubit(); + characterThemeCubit = MockCharacterThemeCubit(); whenListen( - themeCubit, - const Stream.empty(), - initialState: const ThemeState.initial(), + characterThemeCubit, + const Stream.empty(), + initialState: const CharacterThemeState.initial(), ); }); @@ -26,7 +26,7 @@ void main() { testWidgets('renders CharacterSelectionView', (tester) async { await tester.pumpApp( CharacterSelectionDialog(), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); expect(find.byType(CharacterSelectionView), findsOneWidget); }); @@ -46,7 +46,7 @@ void main() { }, ), ), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); await tester.tap(find.text('Tap me')); @@ -61,7 +61,7 @@ void main() { const titleText = 'Choose your character!'; await tester.pumpApp( CharacterSelectionView(), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); expect(find.text(titleText), findsOneWidget); @@ -75,19 +75,20 @@ void main() { await tester.pumpApp( CharacterSelectionView(), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); await tester.tap(find.byKey(sparkyButtonKey)); - verify(() => themeCubit.characterSelected(SparkyTheme())).called(1); + verify(() => characterThemeCubit.characterSelected(SparkyTheme())) + .called(1); }); testWidgets('displays how to play dialog when start is tapped', (tester) async { await tester.pumpApp( CharacterSelectionView(), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); await tester.ensureVisible(find.byType(TextButton)); await tester.tap(find.byType(TextButton)); @@ -100,7 +101,7 @@ void main() { testWidgets('CharacterImageButton renders correctly', (tester) async { await tester.pumpApp( CharacterImageButton(DashTheme()), - themeCubit: themeCubit, + characterThemeCubit: characterThemeCubit, ); expect(find.byType(Image), findsOneWidget); diff --git a/test/theme/cubit/theme_cubit_test.dart b/test/theme/cubit/theme_cubit_test.dart deleted file mode 100644 index 1f2d24e0..00000000 --- a/test/theme/cubit/theme_cubit_test.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball/theme/theme.dart'; -import 'package:pinball_theme/pinball_theme.dart'; - -void main() { - group('ThemeCubit', () { - test('initial state has Dash character theme', () { - final themeCubit = ThemeCubit(); - expect(themeCubit.state.theme.characterTheme, equals(const DashTheme())); - }); - - blocTest( - 'charcterSelected emits selected character theme', - build: ThemeCubit.new, - act: (bloc) => bloc.characterSelected(const SparkyTheme()), - expect: () => [ - const ThemeState(PinballTheme(characterTheme: SparkyTheme())), - ], - ); - }); -} diff --git a/web/index.html b/web/index.html index 3e4978d1..37e17170 100644 --- a/web/index.html +++ b/web/index.html @@ -19,7 +19,31 @@ - + + + + + + + + + + + + + + + + + + + + @@ -107,4 +131,4 @@ - + \ No newline at end of file