diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 8ed906a2..a805cebc 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -13,3 +13,4 @@ jobs: flutter_channel: stable flutter_version: 2.10.0 coverage_excludes: "lib/gen/*.dart" + test_optimization: false diff --git a/firebase.json b/firebase.json index 99025785..f82931ea 100644 --- a/firebase.json +++ b/firebase.json @@ -1,7 +1,33 @@ { + "firestore": { + "rules": "firestore.rules" + }, "hosting": { "public": "build/web", "site": "io-pinball", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "headers": [ + { + "source": "**/*.@(jpg|jpeg|gif|png)", + "headers": [ + { + "key": "Cache-Control", + "value": "max-age=3600" + } + ] + }, + { + "source": "**", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, no-store, must-revalidate" + } + ] + } + ] + }, + "storage": { + "rules": "storage.rules" } } diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 00000000..fbff78f0 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,29 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /leaderboard/{userId} { + + function prohibited(initials) { + let prohibitedInitials = get(/databases/$(database)/documents/prohibitedInitials/list).data.prohibitedInitials; + return initials in prohibitedInitials; + } + + function inCharLimit(initials) { + return initials.size() < 4; + } + + function isAuthedUser(auth) { + return request.auth.uid != null && auth.token.firebase.sign_in_provider == "anonymous" + } + + // Leaderboard can be read if it doesn't contain any prohibited initials + allow read: if !prohibited(resource.data.playerInitials); + + // A leaderboard entry can be created if the user is authenticated, + // it's 3 characters long, and not a prohibited combination. + allow create: if isAuthedUser(request.auth) && + inCharLimit(request.resource.data.playerInitials) && + !prohibited(request.resource.data.playerInitials); + } + } +} \ No newline at end of file diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index d778b55b..a44d2e33 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -8,6 +8,7 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/game/game.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_audio/pinball_audio.dart'; import 'package:pinball_ui/pinball_ui.dart'; @@ -16,15 +17,15 @@ class App extends StatelessWidget { Key? key, required AuthenticationRepository authenticationRepository, required LeaderboardRepository leaderboardRepository, - required PinballAudio pinballAudio, + required PinballPlayer pinballPlayer, }) : _authenticationRepository = authenticationRepository, _leaderboardRepository = leaderboardRepository, - _pinballAudio = pinballAudio, + _pinballPlayer = pinballPlayer, super(key: key); final AuthenticationRepository _authenticationRepository; final LeaderboardRepository _leaderboardRepository; - final PinballAudio _pinballAudio; + final PinballPlayer _pinballPlayer; @override Widget build(BuildContext context) { @@ -32,10 +33,13 @@ class App extends StatelessWidget { providers: [ RepositoryProvider.value(value: _authenticationRepository), RepositoryProvider.value(value: _leaderboardRepository), - RepositoryProvider.value(value: _pinballAudio), + RepositoryProvider.value(value: _pinballPlayer), ], - child: BlocProvider( - create: (context) => CharacterThemeCubit(), + child: MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => CharacterThemeCubit()), + BlocProvider(create: (_) => StartGameBloc()), + ], child: MaterialApp( title: 'I/O Pinball', theme: PinballTheme.standard, diff --git a/lib/game/behaviors/behaviors.dart b/lib/game/behaviors/behaviors.dart new file mode 100644 index 00000000..ae51fc09 --- /dev/null +++ b/lib/game/behaviors/behaviors.dart @@ -0,0 +1,2 @@ +export 'bumper_noisy_behavior.dart'; +export 'scoring_behavior.dart'; diff --git a/lib/game/behaviors/bumper_noisy_behavior.dart b/lib/game/behaviors/bumper_noisy_behavior.dart new file mode 100644 index 00000000..86c9f7b0 --- /dev/null +++ b/lib/game/behaviors/bumper_noisy_behavior.dart @@ -0,0 +1,15 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/pinball_game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class BumperNoisyBehavior extends ContactBehavior with HasGameRef { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + gameRef.player.play(PinballAudio.bumper); + } +} diff --git a/lib/game/behaviors/scoring_behavior.dart b/lib/game/behaviors/scoring_behavior.dart new file mode 100644 index 00000000..eddcb580 --- /dev/null +++ b/lib/game/behaviors/scoring_behavior.dart @@ -0,0 +1,78 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame/components.dart'; +import 'package:flame/effects.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 and shows a text effect. +/// +/// The behavior removes itself after the duration. +/// {@endtemplate} +class ScoringBehavior extends Component with HasGameRef { + /// {@macto scoring_behavior} + ScoringBehavior({ + required Points points, + required Vector2 position, + double duration = 1, + }) : _points = points, + _position = position, + _effectController = EffectController( + duration: duration, + ); + + final Points _points; + final Vector2 _position; + + final EffectController _effectController; + + @override + void update(double dt) { + super.update(dt); + if (_effectController.completed) { + removeFromParent(); + } + } + + @override + Future onLoad() async { + gameRef.read().add(Scored(points: _points.value)); + final canvas = gameRef.descendants().whereType().single; + await canvas.add( + ScoreComponent( + points: _points, + position: _position, + effectController: _effectController, + ), + ); + } +} + +/// {@template scoring_contact_behavior} +/// Adds points to the score when the [Ball] contacts the [parent]. +/// {@endtemplate} +class ScoringContactBehavior extends ContactBehavior + with HasGameRef { + /// {@macro scoring_contact_behavior} + ScoringContactBehavior({ + required Points points, + }) : _points = points; + + final Points _points; + + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + + parent.add( + ScoringBehavior( + points: _points, + position: other.body.position, + ), + ); + } +} diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index 49f40d1f..b22baa14 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -14,31 +14,43 @@ class GameBloc extends Bloc { on(_onIncreasedMultiplier); on(_onBonusActivated); on(_onSparkyTurboChargeActivated); + on(_onGameOver); + on(_onGameStarted); + } + + void _onGameStarted(GameStarted _, Emitter emit) { + emit(state.copyWith(status: GameStatus.playing)); + } + + void _onGameOver(GameOver _, Emitter emit) { + emit(state.copyWith(status: GameStatus.gameOver)); } void _onRoundLost(RoundLost event, Emitter emit) { - final score = state.score * state.multiplier; + final score = state.totalScore + state.roundScore * state.multiplier; final roundsLeft = math.max(state.rounds - 1, 0); emit( state.copyWith( - score: score, + totalScore: score, + roundScore: 0, multiplier: 1, rounds: roundsLeft, + status: roundsLeft == 0 ? GameStatus.gameOver : state.status, ), ); } void _onScored(Scored event, Emitter emit) { - if (!state.isGameOver) { + if (state.status.isPlaying) { emit( - state.copyWith(score: state.score + event.points), + state.copyWith(roundScore: state.roundScore + event.points), ); } } void _onIncreasedMultiplier(MultiplierIncreased event, Emitter emit) { - if (!state.isGameOver) { + if (state.status.isPlaying) { 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 c81ce526..6dba8056 100644 --- a/lib/game/bloc/game_event.dart +++ b/lib/game/bloc/game_event.dart @@ -59,3 +59,17 @@ class MultiplierIncreased extends GameEvent { @override List get props => []; } + +class GameStarted extends GameEvent { + const GameStarted(); + + @override + List get props => []; +} + +class GameOver extends GameEvent { + const GameOver(); + + @override + List get props => []; +} diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 4ce9042d..a9e86720 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -20,28 +20,51 @@ enum GameBonus { androidSpaceship, } +enum GameStatus { + waiting, + playing, + gameOver, +} + +extension GameStatusX on GameStatus { + bool get isPlaying => this == GameStatus.playing; + bool get isGameOver => this == GameStatus.gameOver; +} + /// {@template game_state} /// Represents the state of the pinball game. /// {@endtemplate} class GameState extends Equatable { /// {@macro game_state} const GameState({ - required this.score, + required this.totalScore, + required this.roundScore, required this.multiplier, required this.rounds, required this.bonusHistory, - }) : assert(score >= 0, "Score can't be negative"), + required this.status, + }) : assert(totalScore >= 0, "TotalScore can't be negative"), + assert(roundScore >= 0, "Round score 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, + : status = GameStatus.waiting, + totalScore = 0, + roundScore = 0, multiplier = 1, rounds = 3, bonusHistory = const []; - /// The current score of the game. - final int score; + /// The score for the current round of the game. + /// + /// Multipliers are only applied to the score for the current round once is + /// lost. Then the [roundScore] is added to the [totalScore] and reset to 0 + /// for the next round. + final int roundScore; + + /// The total score of the game. + final int totalScore; /// The current multiplier for the score. final int multiplier; @@ -55,34 +78,42 @@ class GameState extends Equatable { /// PinballGame. final List bonusHistory; - /// Determines when the game is over. - bool get isGameOver => rounds == 0; + final GameStatus status; + + /// The score displayed at the game. + int get displayScore => roundScore + totalScore; GameState copyWith({ - int? score, + int? totalScore, + int? roundScore, int? multiplier, int? balls, int? rounds, List? bonusHistory, + GameStatus? status, }) { assert( - score == null || score >= this.score, - "Score can't be decreased", + totalScore == null || totalScore >= this.totalScore, + "Total score can't be decreased", ); return GameState( - score: score ?? this.score, + totalScore: totalScore ?? this.totalScore, + roundScore: roundScore ?? this.roundScore, multiplier: multiplier ?? this.multiplier, rounds: rounds ?? this.rounds, bonusHistory: bonusHistory ?? this.bonusHistory, + status: status ?? this.status, ); } @override List get props => [ - score, + totalScore, + roundScore, multiplier, rounds, bonusHistory, + status, ]; } diff --git a/lib/game/components/android_acres/android_acres.dart b/lib/game/components/android_acres/android_acres.dart index 3d1a8154..649ef196 100644 --- a/lib/game/components/android_acres/android_acres.dart +++ b/lib/game/components/android_acres/android_acres.dart @@ -2,8 +2,8 @@ import 'package:flame/components.dart'; import 'package:flutter/material.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/components/android_acres/behaviors/behaviors.dart'; -import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; /// {@template android_acres} @@ -15,27 +15,39 @@ class AndroidAcres extends Component { AndroidAcres() : super( children: [ - SpaceshipRamp(), + SpaceshipRamp( + children: [ + RampShotBehavior( + points: Points.fiveThousand, + ), + RampBonusBehavior( + points: Points.oneMillion, + ), + ], + ), SpaceshipRail(), AndroidSpaceship(position: Vector2(-26.5, -28.5)), AndroidAnimatronic( children: [ - ScoringBehavior(points: Points.twoHundredThousand), + ScoringContactBehavior(points: Points.twoHundredThousand), ], )..initialPosition = Vector2(-26, -28.25), AndroidBumper.a( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(-25, 1.3), AndroidBumper.b( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(-32.8, -9.2), AndroidBumper.cow( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(-20.5, -13.8), AndroidSpaceshipBonusBehavior(), diff --git a/lib/game/components/android_acres/behaviors/behaviors.dart b/lib/game/components/android_acres/behaviors/behaviors.dart index e4ac5981..91b1e132 100644 --- a/lib/game/components/android_acres/behaviors/behaviors.dart +++ b/lib/game/components/android_acres/behaviors/behaviors.dart @@ -1 +1,3 @@ export 'android_spaceship_bonus_behavior.dart'; +export 'ramp_bonus_behavior.dart'; +export 'ramp_shot_behavior.dart'; diff --git a/lib/game/components/android_acres/behaviors/ramp_bonus_behavior.dart b/lib/game/components/android_acres/behaviors/ramp_bonus_behavior.dart new file mode 100644 index 00000000..218ad8b4 --- /dev/null +++ b/lib/game/components/android_acres/behaviors/ramp_bonus_behavior.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template ramp_bonus_behavior} +/// Increases the score when a [Ball] is shot 10 times into the [SpaceshipRamp]. +/// {@endtemplate} +class RampBonusBehavior extends Component + with ParentIsA, HasGameRef { + /// {@macro ramp_bonus_behavior} + RampBonusBehavior({ + required Points points, + }) : _points = points, + super(); + + /// Creates a [RampBonusBehavior]. + /// + /// This can be used for testing [RampBonusBehavior] in isolation. + @visibleForTesting + RampBonusBehavior.test({ + required Points points, + required this.subscription, + }) : _points = points, + super(); + + final Points _points; + + /// Subscription to [SpaceshipRampState] at [SpaceshipRamp]. + @visibleForTesting + StreamSubscription? subscription; + + @override + void onMount() { + super.onMount(); + + subscription = subscription ?? + parent.bloc.stream.listen((state) { + final achievedOneMillionPoints = state.hits % 10 == 0; + + if (achievedOneMillionPoints) { + parent.add( + ScoringBehavior( + points: _points, + position: Vector2(0, -60), + duration: 2, + ), + ); + } + }); + } + + @override + void onRemove() { + subscription?.cancel(); + super.onRemove(); + } +} diff --git a/lib/game/components/android_acres/behaviors/ramp_shot_behavior.dart b/lib/game/components/android_acres/behaviors/ramp_shot_behavior.dart new file mode 100644 index 00000000..8a9c1a9c --- /dev/null +++ b/lib/game/components/android_acres/behaviors/ramp_shot_behavior.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template ramp_shot_behavior} +/// Increases the score when a [Ball] is shot into the [SpaceshipRamp]. +/// {@endtemplate} +class RampShotBehavior extends Component + with ParentIsA, HasGameRef { + /// {@macro ramp_shot_behavior} + RampShotBehavior({ + required Points points, + }) : _points = points, + super(); + + /// Creates a [RampShotBehavior]. + /// + /// This can be used for testing [RampShotBehavior] in isolation. + @visibleForTesting + RampShotBehavior.test({ + required Points points, + required this.subscription, + }) : _points = points, + super(); + + final Points _points; + + /// Subscription to [SpaceshipRampState] at [SpaceshipRamp]. + @visibleForTesting + StreamSubscription? subscription; + + @override + void onMount() { + super.onMount(); + + subscription = subscription ?? + parent.bloc.stream.listen((state) { + final achievedOneMillionPoints = state.hits % 10 == 0; + + if (!achievedOneMillionPoints) { + gameRef.read().add(const MultiplierIncreased()); + + parent.add( + ScoringBehavior( + points: _points, + position: Vector2(0, -45), + ), + ); + } + }); + } + + @override + void onRemove() { + subscription?.cancel(); + super.onRemove(); + } +} diff --git a/lib/game/components/backbox/backbox.dart b/lib/game/components/backbox/backbox.dart new file mode 100644 index 00000000..30b2a1aa --- /dev/null +++ b/lib/game/components/backbox/backbox.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball/game/components/backbox/displays/displays.dart'; +import 'package:pinball/game/pinball_game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart' hide Assets; + +/// {@template backbox} +/// The [Backbox] of the pinball machine. +/// {@endtemplate} +class Backbox extends PositionComponent with HasGameRef, ZIndex { + /// {@macro backbox} + Backbox({ + required LeaderboardRepository leaderboardRepository, + }) : _bloc = BackboxBloc(leaderboardRepository: leaderboardRepository); + + /// {@macro backbox} + @visibleForTesting + Backbox.test({ + required BackboxBloc bloc, + }) : _bloc = bloc; + + late final Component _display; + final BackboxBloc _bloc; + late StreamSubscription _subscription; + + @override + Future onLoad() async { + position = Vector2(0, -87); + anchor = Anchor.bottomCenter; + zIndex = ZIndexes.backbox; + + await add(_BackboxSpriteComponent()); + await add(_display = Component()); + + _subscription = _bloc.stream.listen((state) { + _display.children.removeWhere((_) => true); + _build(state); + }); + } + + @override + void onRemove() { + super.onRemove(); + _subscription.cancel(); + } + + void _build(BackboxState state) { + if (state is LoadingState) { + _display.add(LoadingDisplay()); + } else if (state is InitialsFormState) { + _display.add( + InitialsInputDisplay( + score: state.score, + characterIconPath: state.character.leaderboardIcon.keyName, + onSubmit: (initials) { + _bloc.add( + PlayerInitialsSubmitted( + score: state.score, + initials: initials, + character: state.character, + ), + ); + }, + ), + ); + } else if (state is InitialsSuccessState) { + _display.add(InitialsSubmissionSuccessDisplay()); + } else if (state is InitialsFailureState) { + _display.add(InitialsSubmissionFailureDisplay()); + } + } + + /// Puts [InitialsInputDisplay] on the [Backbox]. + void requestInitials({ + required int score, + required CharacterTheme character, + }) { + _bloc.add( + PlayerInitialsRequested( + score: score, + character: character, + ), + ); + } +} + +class _BackboxSpriteComponent extends SpriteComponent with HasGameRef { + _BackboxSpriteComponent() : super(anchor: Anchor.bottomCenter); + + @override + Future onLoad() async { + await super.onLoad(); + + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.backbox.marquee.keyName, + ), + ); + this.sprite = sprite; + size = sprite.originalSize / 20; + } +} diff --git a/lib/game/components/backbox/bloc/backbox_bloc.dart b/lib/game/components/backbox/bloc/backbox_bloc.dart new file mode 100644 index 00000000..f315189e --- /dev/null +++ b/lib/game/components/backbox/bloc/backbox_bloc.dart @@ -0,0 +1,56 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/leaderboard/models/leader_board_entry.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +part 'backbox_event.dart'; +part 'backbox_state.dart'; + +/// {@template backbox_bloc} +/// Bloc which manages the Backbox display. +/// {@endtemplate} +class BackboxBloc extends Bloc { + /// {@macro backbox_bloc} + BackboxBloc({ + required LeaderboardRepository leaderboardRepository, + }) : _leaderboardRepository = leaderboardRepository, + super(LoadingState()) { + on(_onPlayerInitialsRequested); + on(_onPlayerInitialsSubmitted); + } + + final LeaderboardRepository _leaderboardRepository; + + void _onPlayerInitialsRequested( + PlayerInitialsRequested event, + Emitter emit, + ) { + emit( + InitialsFormState( + score: event.score, + character: event.character, + ), + ); + } + + Future _onPlayerInitialsSubmitted( + PlayerInitialsSubmitted event, + Emitter emit, + ) async { + try { + emit(LoadingState()); + await _leaderboardRepository.addLeaderboardEntry( + LeaderboardEntryData( + playerInitials: event.initials, + score: event.score, + character: event.character.toType, + ), + ); + emit(InitialsSuccessState()); + } catch (error, stackTrace) { + addError(error, stackTrace); + emit(InitialsFailureState()); + } + } +} diff --git a/lib/game/components/backbox/bloc/backbox_event.dart b/lib/game/components/backbox/bloc/backbox_event.dart new file mode 100644 index 00000000..42203cdc --- /dev/null +++ b/lib/game/components/backbox/bloc/backbox_event.dart @@ -0,0 +1,53 @@ +part of 'backbox_bloc.dart'; + +/// {@template backbox_event} +/// Base class for backbox events. +/// {@endtemplate} +abstract class BackboxEvent extends Equatable { + /// {@macro backbox_event} + const BackboxEvent(); +} + +/// {@template player_initials_requested} +/// Event that triggers the user initials display. +/// {@endtemplate} +class PlayerInitialsRequested extends BackboxEvent { + /// {@macro player_initials_requested} + const PlayerInitialsRequested({ + required this.score, + required this.character, + }); + + /// Player's score. + final int score; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, character]; +} + +/// {@template player_initials_submitted} +/// Event that submits the user score and initials. +/// {@endtemplate} +class PlayerInitialsSubmitted extends BackboxEvent { + /// {@macro player_initials_submitted} + const PlayerInitialsSubmitted({ + required this.score, + required this.initials, + required this.character, + }); + + /// Player's score. + final int score; + + /// Player's initials. + final String initials; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, initials, character]; +} diff --git a/lib/game/components/backbox/bloc/backbox_state.dart b/lib/game/components/backbox/bloc/backbox_state.dart new file mode 100644 index 00000000..e1f2c801 --- /dev/null +++ b/lib/game/components/backbox/bloc/backbox_state.dart @@ -0,0 +1,59 @@ +part of 'backbox_bloc.dart'; + +/// {@template backbox_state} +/// The base state for all [BackboxState]. +/// {@endtemplate backbox_state} +abstract class BackboxState extends Equatable { + /// {@macro backbox_state} + const BackboxState(); +} + +/// Loading state for the backbox. +class LoadingState extends BackboxState { + @override + List get props => []; +} + +/// State when the leaderboard was successfully loaded. +class LeaderboardSuccessState extends BackboxState { + @override + List get props => []; +} + +/// State when the leaderboard failed to load. +class LeaderboardFailureState extends BackboxState { + @override + List get props => []; +} + +/// {@template initials_form_state} +/// State when the user is inputting their initials. +/// {@endtemplate} +class InitialsFormState extends BackboxState { + /// {@macro initials_form_state} + const InitialsFormState({ + required this.score, + required this.character, + }) : super(); + + /// Player's score. + final int score; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, character]; +} + +/// State when the leaderboard was successfully loaded. +class InitialsSuccessState extends BackboxState { + @override + List get props => []; +} + +/// State when the initials submission failed. +class InitialsFailureState extends BackboxState { + @override + List get props => []; +} diff --git a/lib/game/components/backbox/displays/displays.dart b/lib/game/components/backbox/displays/displays.dart new file mode 100644 index 00000000..a516587d --- /dev/null +++ b/lib/game/components/backbox/displays/displays.dart @@ -0,0 +1,4 @@ +export 'initials_input_display.dart'; +export 'initials_submission_failure_display.dart'; +export 'initials_submission_success_display.dart'; +export 'loading_display.dart'; diff --git a/lib/game/components/backbox/displays/initials_input_display.dart b/lib/game/components/backbox/displays/initials_input_display.dart new file mode 100644 index 00000000..fd286d62 --- /dev/null +++ b/lib/game/components/backbox/displays/initials_input_display.dart @@ -0,0 +1,387 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +/// Signature for the callback called when the used has +/// submitted their initials on the [InitialsInputDisplay]. +typedef InitialsOnSubmit = void Function(String); + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 3, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +final _subtitleTextPaint = TextPaint( + style: const TextStyle( + fontSize: 1.8, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template initials_input_display} +/// Display that handles the user input on the game over view. +/// {@endtemplate} +// TODO(allisonryan0002): add mobile input buttons. +class InitialsInputDisplay extends Component with HasGameRef { + /// {@macro initials_input_display} + InitialsInputDisplay({ + required int score, + required String characterIconPath, + InitialsOnSubmit? onSubmit, + }) : _onSubmit = onSubmit, + super( + children: [ + _ScoreLabelTextComponent(), + _ScoreTextComponent(score.formatScore()), + _NameLabelTextComponent(), + _CharacterIconSpriteComponent(characterIconPath), + _DividerSpriteComponent(), + _InstructionsComponent(), + ], + ); + + final InitialsOnSubmit? _onSubmit; + + @override + Future onLoad() async { + for (var i = 0; i < 3; i++) { + await add( + InitialsLetterPrompt( + position: Vector2( + 11.4 + (2.3 * i), + -20, + ), + hasFocus: i == 0, + ), + ); + } + + await add( + KeyboardInputController( + keyUp: { + LogicalKeyboardKey.arrowLeft: () => _movePrompt(true), + LogicalKeyboardKey.arrowRight: () => _movePrompt(false), + LogicalKeyboardKey.enter: _submit, + }, + ), + ); + } + + /// Returns the current inputed initials + String get initials => children + .whereType() + .map((prompt) => prompt.char) + .join(); + + bool _submit() { + _onSubmit?.call(initials); + return true; + } + + bool _movePrompt(bool left) { + final prompts = children.whereType().toList(); + + final current = prompts.firstWhere((prompt) => prompt.hasFocus) + ..hasFocus = false; + var index = prompts.indexOf(current) + (left ? -1 : 1); + index = min(max(0, index), prompts.length - 1); + + prompts[index].hasFocus = true; + + return false; + } +} + +class _ScoreLabelTextComponent extends TextComponent + with HasGameRef { + _ScoreLabelTextComponent() + : super( + anchor: Anchor.centerLeft, + position: Vector2(-16.9, -24), + textRenderer: _bodyTextPaint.copyWith( + (style) => style.copyWith( + color: PinballColors.red, + ), + ), + ); + + @override + Future onLoad() async { + await super.onLoad(); + text = gameRef.l10n.score; + } +} + +class _ScoreTextComponent extends TextComponent { + _ScoreTextComponent(String score) + : super( + text: score, + anchor: Anchor.centerLeft, + position: Vector2(-16.9, -20), + textRenderer: _bodyTextPaint, + ); +} + +class _NameLabelTextComponent extends TextComponent + with HasGameRef { + _NameLabelTextComponent() + : super( + anchor: Anchor.center, + position: Vector2(11.4, -24), + textRenderer: _bodyTextPaint.copyWith( + (style) => style.copyWith( + color: PinballColors.red, + ), + ), + ); + + @override + Future onLoad() async { + await super.onLoad(); + text = gameRef.l10n.name; + } +} + +class _CharacterIconSpriteComponent extends SpriteComponent with HasGameRef { + _CharacterIconSpriteComponent(String characterIconPath) + : _characterIconPath = characterIconPath, + super( + anchor: Anchor.center, + position: Vector2(8.4, -20), + ); + + final String _characterIconPath; + + @override + Future onLoad() async { + await super.onLoad(); + final sprite = Sprite(gameRef.images.fromCache(_characterIconPath)); + this.sprite = sprite; + size = sprite.originalSize / 20; + } +} + +/// {@template initials_input_display} +/// Display that handles the user input on the game over view. +/// {@endtemplate} +@visibleForTesting +class InitialsLetterPrompt extends PositionComponent { + /// {@macro initials_input_display} + InitialsLetterPrompt({ + required Vector2 position, + bool hasFocus = false, + }) : _hasFocus = hasFocus, + super( + position: position, + ); + + static const _alphabetCode = 65; + static const _alphabetLength = 25; + var _charIndex = 0; + + bool _hasFocus; + + late RectangleComponent _underscore; + late TextComponent _input; + late TimerComponent _underscoreBlinker; + + @override + Future onLoad() async { + _underscore = RectangleComponent( + size: Vector2(1.9, 0.4), + anchor: Anchor.center, + position: Vector2(-0.1, 1.8), + ); + + await add(_underscore); + + _input = TextComponent( + text: 'A', + textRenderer: _bodyTextPaint, + anchor: Anchor.center, + ); + await add(_input); + + _underscoreBlinker = TimerComponent( + period: 0.6, + repeat: true, + autoStart: _hasFocus, + onTick: () { + _underscore.paint.color = (_underscore.paint.color == Colors.white) + ? Colors.transparent + : Colors.white; + }, + ); + + await add(_underscoreBlinker); + + await add( + KeyboardInputController( + keyUp: { + LogicalKeyboardKey.arrowUp: () => _cycle(true), + LogicalKeyboardKey.arrowDown: () => _cycle(false), + }, + ), + ); + } + + /// Returns the current selected character + String get char => String.fromCharCode(_alphabetCode + _charIndex); + + bool _cycle(bool up) { + if (_hasFocus) { + final newCharCode = + min(max(_charIndex + (up ? 1 : -1), 0), _alphabetLength); + _input.text = String.fromCharCode(_alphabetCode + newCharCode); + _charIndex = newCharCode; + + return false; + } + return true; + } + + /// Returns if this prompt has focus on it + bool get hasFocus => _hasFocus; + + /// Updates this prompt focus + set hasFocus(bool hasFocus) { + if (hasFocus) { + _underscoreBlinker.timer.resume(); + } else { + _underscoreBlinker.timer.pause(); + } + _underscore.paint.color = Colors.white; + _hasFocus = hasFocus; + } +} + +class _DividerSpriteComponent extends SpriteComponent with HasGameRef { + _DividerSpriteComponent() + : super( + anchor: Anchor.center, + position: Vector2(0, -17), + ); + + @override + Future onLoad() async { + await super.onLoad(); + final sprite = Sprite( + gameRef.images.fromCache(Assets.images.backbox.displayDivider.keyName), + ); + this.sprite = sprite; + size = sprite.originalSize / 20; + } +} + +class _InstructionsComponent extends PositionComponent with HasGameRef { + _InstructionsComponent() + : super( + anchor: Anchor.center, + position: Vector2(0, -12.3), + children: [ + _EnterInitialsTextComponent(), + _ArrowsTextComponent(), + _AndPressTextComponent(), + _EnterReturnTextComponent(), + _ToSubmitTextComponent(), + ], + ); +} + +class _EnterInitialsTextComponent extends TextComponent + with HasGameRef { + _EnterInitialsTextComponent() + : super( + anchor: Anchor.center, + position: Vector2(0, -2.4), + textRenderer: _subtitleTextPaint, + ); + + @override + Future onLoad() async { + await super.onLoad(); + text = gameRef.l10n.enterInitials; + } +} + +class _ArrowsTextComponent extends TextComponent with HasGameRef { + _ArrowsTextComponent() + : super( + anchor: Anchor.center, + position: Vector2(-13.2, 0), + textRenderer: _subtitleTextPaint.copyWith( + (style) => style.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ); + + @override + Future onLoad() async { + await super.onLoad(); + text = gameRef.l10n.arrows; + } +} + +class _AndPressTextComponent extends TextComponent + with HasGameRef { + _AndPressTextComponent() + : super( + anchor: Anchor.center, + position: Vector2(-3.7, 0), + textRenderer: _subtitleTextPaint, + ); + + @override + Future onLoad() async { + await super.onLoad(); + text = gameRef.l10n.andPress; + } +} + +class _EnterReturnTextComponent extends TextComponent + with HasGameRef { + _EnterReturnTextComponent() + : super( + anchor: Anchor.center, + position: Vector2(10, 0), + textRenderer: _subtitleTextPaint.copyWith( + (style) => style.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ); + + @override + Future onLoad() async { + await super.onLoad(); + text = gameRef.l10n.enterReturn; + } +} + +class _ToSubmitTextComponent extends TextComponent + with HasGameRef { + _ToSubmitTextComponent() + : super( + anchor: Anchor.center, + position: Vector2(0, 2.4), + textRenderer: _subtitleTextPaint, + ); + + @override + Future onLoad() async { + await super.onLoad(); + text = gameRef.l10n.toSubmit; + } +} diff --git a/lib/game/components/backbox/displays/initials_submission_failure_display.dart b/lib/game/components/backbox/displays/initials_submission_failure_display.dart new file mode 100644 index 00000000..178354c2 --- /dev/null +++ b/lib/game/components/backbox/displays/initials_submission_failure_display.dart @@ -0,0 +1,28 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 3, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template initials_submission_failure_display} +/// [Backbox] display for when a failure occurs during initials submission. +/// {@endtemplate} +class InitialsSubmissionFailureDisplay extends TextComponent + with HasGameRef { + @override + Future onLoad() async { + await super.onLoad(); + position = Vector2(0, -10); + anchor = Anchor.center; + text = 'Failure!'; + textRenderer = _bodyTextPaint; + } +} diff --git a/lib/game/components/backbox/displays/initials_submission_success_display.dart b/lib/game/components/backbox/displays/initials_submission_success_display.dart new file mode 100644 index 00000000..46c35b0e --- /dev/null +++ b/lib/game/components/backbox/displays/initials_submission_success_display.dart @@ -0,0 +1,28 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 3, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template initials_submission_success_display} +/// [Backbox] display for initials successfully submitted. +/// {@endtemplate} +class InitialsSubmissionSuccessDisplay extends TextComponent + with HasGameRef { + @override + Future onLoad() async { + await super.onLoad(); + position = Vector2(0, -10); + anchor = Anchor.center; + text = 'Success!'; + textRenderer = _bodyTextPaint; + } +} diff --git a/lib/game/components/backbox/displays/loading_display.dart b/lib/game/components/backbox/displays/loading_display.dart new file mode 100644 index 00000000..7b1d4280 --- /dev/null +++ b/lib/game/components/backbox/displays/loading_display.dart @@ -0,0 +1,48 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 3, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template loading_display} +/// Display used to show the loading animation. +/// {@endtemplate} +class LoadingDisplay extends TextComponent with HasGameRef { + /// {@template loading_display} + LoadingDisplay(); + + late final String _label; + + @override + Future onLoad() async { + await super.onLoad(); + + position = Vector2(0, -10); + anchor = Anchor.center; + text = _label = gameRef.l10n.loading; + textRenderer = _bodyTextPaint; + + await add( + TimerComponent( + period: 1, + repeat: true, + onTick: () { + final index = text.indexOf('.'); + if (index != -1 && text.substring(index).length == 3) { + text = _label; + } else { + text = '$text.'; + } + }, + ), + ); + } +} diff --git a/lib/game/components/bottom_group.dart b/lib/game/components/bottom_group.dart index c13f21be..d7856e48 100644 --- a/lib/game/components/bottom_group.dart +++ b/lib/game/components/bottom_group.dart @@ -1,4 +1,5 @@ import 'package:flame/components.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; @@ -51,7 +52,8 @@ class _BottomGroupSide extends Component { final kicker = Kicker( side: _side, children: [ - ScoringBehavior(points: Points.fiveThousand)..applyTo(['bouncy_edge']), + ScoringContactBehavior(points: Points.fiveThousand) + ..applyTo(['bouncy_edge']), ], )..initialPosition = Vector2( (22.64 * direction) + centerXAdjustment, diff --git a/lib/game/components/camera_controller.dart b/lib/game/components/camera_controller.dart index a411942e..083e5745 100644 --- a/lib/game/components/camera_controller.dart +++ b/lib/game/components/camera_controller.dart @@ -3,15 +3,15 @@ import 'package:flame/game.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; -/// Adds helpers methods to Flame's [Camera] +/// Adds helpers methods to Flame's [Camera]. extension CameraX on Camera { - /// Instantly apply the point of focus to the [Camera] + /// Instantly apply the point of focus to the [Camera]. void snapToFocus(FocusData data) { followVector2(data.position); zoom = data.zoom; } - /// Returns a [CameraZoom] that can be added to a [FlameGame] + /// Returns a [CameraZoom] that can be added to a [FlameGame]. CameraZoom focusToCameraZoom(FocusData data) { final zoom = CameraZoom(value: data.zoom); zoom.completed.then((_) { @@ -22,7 +22,7 @@ extension CameraX on Camera { } /// {@template focus_data} -/// Model class that defines a focus point of the camera +/// Model class that defines a focus point of the camera. /// {@endtemplate} class FocusData { /// {@template focus_data} @@ -31,50 +31,63 @@ class FocusData { required this.position, }); - /// The amount of zoom + /// The amount of zoom. final double zoom; - /// The position of the camera + /// The position of the camera. final Vector2 position; } /// {@template camera_controller} -/// A [Component] that controls its game camera focus +/// A [Component] that controls its game camera focus. /// {@endtemplate} class CameraController extends ComponentController { /// {@macro camera_controller} CameraController(FlameGame component) : super(component) { final gameZoom = component.size.y / 16; - final backboardZoom = component.size.y / 18; + final waitingBackboxZoom = component.size.y / 18; + final gameOverBackboxZoom = component.size.y / 10; gameFocus = FocusData( zoom: gameZoom, position: Vector2(0, -7.8), ); - backboardFocus = FocusData( - zoom: backboardZoom, - position: Vector2(0, -100.8), + waitingBackboxFocus = FocusData( + zoom: waitingBackboxZoom, + position: Vector2(0, -112), + ); + gameOverBackboxFocus = FocusData( + zoom: gameOverBackboxZoom, + position: Vector2(0, -111), ); - // Game starts with the camera focused on the panel + // Game starts with the camera focused on the [Backbox]. component.camera ..speed = 100 - ..snapToFocus(backboardFocus); + ..snapToFocus(waitingBackboxFocus); } - /// Holds the data for the game focus point + /// Holds the data for the game focus point. late final FocusData gameFocus; - /// Holds the data for the backboard focus point - late final FocusData backboardFocus; + /// Holds the data for the waiting backbox focus point. + late final FocusData waitingBackboxFocus; + + /// Holds the data for the game over backbox focus point. + late final FocusData gameOverBackboxFocus; - /// Move the camera focus to the game board + /// Move the camera focus to the game board. void focusOnGame() { component.add(component.camera.focusToCameraZoom(gameFocus)); } - /// Move the camera focus to the backboard - void focusOnBackboard() { - component.add(component.camera.focusToCameraZoom(backboardFocus)); + /// Move the camera focus to the waiting backbox. + void focusOnWaitingBackbox() { + component.add(component.camera.focusToCameraZoom(waitingBackboxFocus)); + } + + /// Move the camera focus to the game over backbox. + void focusOnGameOverBackbox() { + component.add(component.camera.focusToCameraZoom(gameOverBackboxFocus)); } } diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index b0b81239..c8a71cee 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,4 +1,5 @@ export 'android_acres/android_acres.dart'; +export 'backbox/backbox.dart'; export 'bottom_group.dart'; export 'camera_controller.dart'; export 'controlled_ball.dart'; @@ -7,10 +8,9 @@ export 'controlled_plunger.dart'; export 'dino_desert/dino_desert.dart'; export 'drain.dart'; export 'flutter_forest/flutter_forest.dart'; -export 'game_flow_controller.dart'; +export 'game_bloc_status_listener.dart'; export 'google_word/google_word.dart'; export 'launcher.dart'; export 'multiballs/multiballs.dart'; export 'multipliers/multipliers.dart'; -export 'scoring_behavior.dart'; export 'sparky_scorch.dart'; diff --git a/lib/game/components/controlled_ball.dart b/lib/game/components/controlled_ball.dart index 4103bb81..132639d4 100644 --- a/lib/game/components/controlled_ball.dart +++ b/lib/game/components/controlled_ball.dart @@ -1,7 +1,6 @@ // ignore_for_file: avoid_renaming_method_parameters import 'package:flame/components.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,7 +16,7 @@ class ControlledBall extends Ball with Controls { /// A [Ball] that launches from the [Plunger]. ControlledBall.launch({ required CharacterTheme characterTheme, - }) : super(baseColor: characterTheme.ballColor) { + }) : super(assetPath: characterTheme.ball.keyName) { controller = BallController(this); layer = Layer.launcher; zIndex = ZIndexes.ballOnLaunchRamp; @@ -28,13 +27,13 @@ class ControlledBall extends Ball with Controls { /// {@endtemplate} ControlledBall.bonus({ required CharacterTheme characterTheme, - }) : super(baseColor: characterTheme.ballColor) { + }) : super(assetPath: characterTheme.ball.keyName) { controller = BallController(this); zIndex = ZIndexes.ballOnBoard; } /// [Ball] used in [DebugPinballGame]. - ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) { + ControlledBall.debug() : super() { controller = BallController(this); zIndex = ZIndexes.ballOnBoard; } @@ -67,7 +66,9 @@ class BallController extends ComponentController const Duration(milliseconds: 2583), ); component.resume(); - await component.boost(Vector2(40, 110)); + await component.add( + BallTurboChargingBehavior(impulse: Vector2(40, 110)), + ); } @override diff --git a/lib/game/components/controlled_flipper.dart b/lib/game/components/controlled_flipper.dart index 3c82e719..9d5a8164 100644 --- a/lib/game/components/controlled_flipper.dart +++ b/lib/game/components/controlled_flipper.dart @@ -37,7 +37,7 @@ class FlipperController extends ComponentController RawKeyEvent event, Set keysPressed, ) { - if (state?.isGameOver ?? false) return true; + if (state?.status.isGameOver ?? false) return true; if (!_keys.contains(event.logicalKey)) return true; if (event is RawKeyDownEvent) { diff --git a/lib/game/components/controlled_plunger.dart b/lib/game/components/controlled_plunger.dart index d6c622f7..999fae5e 100644 --- a/lib/game/components/controlled_plunger.dart +++ b/lib/game/components/controlled_plunger.dart @@ -38,7 +38,7 @@ class PlungerController extends ComponentController RawKeyEvent event, Set keysPressed, ) { - if (state?.isGameOver ?? false) return true; + if (state?.status.isGameOver ?? false) return true; if (!_keys.contains(event.logicalKey)) return true; if (event is RawKeyDownEvent) { diff --git a/lib/game/components/dino_desert/dino_desert.dart b/lib/game/components/dino_desert/dino_desert.dart index 4d8cd7b6..5f01979f 100644 --- a/lib/game/components/dino_desert/dino_desert.dart +++ b/lib/game/components/dino_desert/dino_desert.dart @@ -1,6 +1,7 @@ import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/components/dino_desert/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -16,7 +17,7 @@ class DinoDesert extends Component { children: [ ChromeDino( children: [ - ScoringBehavior(points: Points.twoHundredThousand) + ScoringContactBehavior(points: Points.twoHundredThousand) ..applyTo(['inside_mouth']), ], )..initialPosition = Vector2(12.6, -6.9), @@ -35,12 +36,14 @@ class DinoDesert extends Component { } class _BarrierBehindDino extends BodyComponent { + _BarrierBehindDino() : super(renderBody: false); + @override Body createBody() { final shape = EdgeShape() ..set( - Vector2(25, -14.2), - Vector2(25, -7.7), + Vector2(25.3, -14.2), + Vector2(25.3, -7.7), ); return world.createBody(BodyDef())..createFixtureFromShape(shape); diff --git a/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart b/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart index 8f1b46e8..c06e6f87 100644 --- a/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart +++ b/lib/game/components/flutter_forest/behaviors/flutter_forest_bonus_behavior.dart @@ -17,7 +17,7 @@ class FlutterForestBonusBehavior extends Component final bumpers = parent.children.whereType(); final signpost = parent.firstChild()!; final animatronic = parent.firstChild()!; - final canvas = gameRef.firstChild()!; + final canvas = gameRef.descendants().whereType().single; for (final bumper in bumpers) { // TODO(alestiago): Refactor subscription management once the following is diff --git a/lib/game/components/flutter_forest/flutter_forest.dart b/lib/game/components/flutter_forest/flutter_forest.dart index 1fb8907b..259b6bb2 100644 --- a/lib/game/components/flutter_forest/flutter_forest.dart +++ b/lib/game/components/flutter_forest/flutter_forest.dart @@ -2,8 +2,8 @@ import 'package:flame/components.dart'; import 'package:flutter/material.dart'; +import 'package:pinball/game/behaviors/behaviors.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 'package:pinball_flame/pinball_flame.dart'; @@ -18,22 +18,26 @@ class FlutterForest extends Component with ZIndex { children: [ Signpost( children: [ - ScoringBehavior(points: Points.fiveThousand), + ScoringContactBehavior(points: Points.fiveThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(8.35, -58.3), DashNestBumper.main( children: [ - ScoringBehavior(points: Points.twoHundredThousand), + ScoringContactBehavior(points: Points.twoHundredThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(18.55, -59.35), DashNestBumper.a( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(8.95, -51.95), DashNestBumper.b( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(22.3, -46.75), DashAnimatronic()..position = Vector2(20, -66), diff --git a/lib/game/components/game_bloc_status_listener.dart b/lib/game/components/game_bloc_status_listener.dart new file mode 100644 index 00000000..1984a523 --- /dev/null +++ b/lib/game/components/game_bloc_status_listener.dart @@ -0,0 +1,33 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; + +/// Listens to the [GameBloc] and updates the game accordingly. +class GameBlocStatusListener extends Component + with BlocComponent, HasGameRef { + @override + bool listenWhen(GameState? previousState, GameState newState) { + return previousState?.status != newState.status; + } + + @override + void onNewState(GameState state) { + switch (state.status) { + case GameStatus.waiting: + break; + case GameStatus.playing: + gameRef.player.play(PinballAudio.backgroundMusic); + gameRef.firstChild()?.focusOnGame(); + gameRef.overlays.remove(PinballGame.playButtonOverlay); + break; + case GameStatus.gameOver: + gameRef.descendants().whereType().first.requestInitials( + score: state.displayScore, + character: gameRef.characterTheme, + ); + gameRef.firstChild()!.focusOnGameOverBackbox(); + break; + } + } +} diff --git a/lib/game/components/game_flow_controller.dart b/lib/game/components/game_flow_controller.dart deleted file mode 100644 index edc65329..00000000 --- a/lib/game/components/game_flow_controller.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame_bloc/flame_bloc.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template game_flow_controller} -/// A [Component] that controls the game over and game restart logic -/// {@endtemplate} -class GameFlowController extends ComponentController - with BlocComponent { - /// {@macro game_flow_controller} - GameFlowController(PinballGame component) : super(component); - - @override - bool listenWhen(GameState? previousState, GameState newState) { - return previousState?.isGameOver != newState.isGameOver; - } - - @override - void onNewState(GameState state) { - if (state.isGameOver) { - gameOver(); - } else { - start(); - } - } - - /// Puts the game on a game over state - void gameOver() { - // TODO(erickzanardo): implement score submission and "navigate" to the - // next page - component.firstChild()?.gameOverMode( - score: state?.score ?? 0, - characterIconPath: component.characterTheme.leaderboardIcon.keyName, - ); - component.firstChild()?.focusOnBackboard(); - } - - /// Puts the game on a playing state - void start() { - component.audio.backgroundMusic(); - component.firstChild()?.waitingMode(); - component.firstChild()?.focusOnGame(); - component.overlays.remove(PinballGame.playButtonOverlay); - } -} diff --git a/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart b/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart index cb9ad308..a9522e76 100644 --- a/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart +++ b/lib/game/components/google_word/behaviors/google_word_bonus_behavior.dart @@ -1,5 +1,6 @@ import 'package:flame/components.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; @@ -20,7 +21,7 @@ class GoogleWordBonusBehavior extends Component .every((letter) => letter.bloc.state == GoogleLetterState.lit); if (achievedBonus) { - gameRef.audio.googleBonus(); + gameRef.player.play(PinballAudio.google); gameRef .read() .add(const BonusActivated(GameBonus.googleWord)); diff --git a/lib/game/components/google_word/google_word.dart b/lib/game/components/google_word/google_word.dart index af1faea9..76bac244 100644 --- a/lib/game/components/google_word/google_word.dart +++ b/lib/game/components/google_word/google_word.dart @@ -1,7 +1,7 @@ import 'package:flame/components.dart'; import 'package:flutter/material.dart'; +import 'package:pinball/game/behaviors/scoring_behavior.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 'package:pinball_flame/pinball_flame.dart'; @@ -16,27 +16,27 @@ class GoogleWord extends Component with ZIndex { children: [ GoogleLetter( 0, - children: [ScoringBehavior(points: Points.fiveThousand)], + children: [ScoringContactBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(-13.1, 1.72), GoogleLetter( 1, - children: [ScoringBehavior(points: Points.fiveThousand)], + children: [ScoringContactBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(-8.33, -0.75), GoogleLetter( 2, - children: [ScoringBehavior(points: Points.fiveThousand)], + children: [ScoringContactBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(-2.88, -1.85), GoogleLetter( 3, - children: [ScoringBehavior(points: Points.fiveThousand)], + children: [ScoringContactBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(2.88, -1.85), GoogleLetter( 4, - children: [ScoringBehavior(points: Points.fiveThousand)], + children: [ScoringContactBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(8.33, -0.75), GoogleLetter( 5, - children: [ScoringBehavior(points: Points.fiveThousand)], + children: [ScoringContactBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(13.1, 1.72), GoogleWordBonusBehavior(), ], diff --git a/lib/game/components/launcher.dart b/lib/game/components/launcher.dart index ffac6507..da1a3569 100644 --- a/lib/game/components/launcher.dart +++ b/lib/game/components/launcher.dart @@ -12,6 +12,7 @@ class Launcher extends Component { : super( children: [ LaunchRamp(), + Flapper(), ControlledPlunger(compressionDistance: 9.2) ..initialPosition = Vector2(41.2, 43.7), RocketSpriteComponent()..position = Vector2(43, 62.3), diff --git a/lib/game/components/scoring_behavior.dart b/lib/game/components/scoring_behavior.dart deleted file mode 100644 index e8f51e90..00000000 --- a/lib/game/components/scoring_behavior.dart +++ /dev/null @@ -1,34 +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 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 Points points, - }) : _points = points; - - final Points _points; - - @override - void beginContact(Object other, Contact contact) { - super.beginContact(other, contact); - if (other is! Ball) return; - - gameRef.read().add(Scored(points: _points.value)); - gameRef.audio.score(); - gameRef.firstChild()!.add( - ScoreComponent( - points: _points, - position: other.body.position, - ), - ); - } -} diff --git a/lib/game/components/sparky_scorch.dart b/lib/game/components/sparky_scorch.dart index 434e9479..5a266b4e 100644 --- a/lib/game/components/sparky_scorch.dart +++ b/lib/game/components/sparky_scorch.dart @@ -2,7 +2,8 @@ import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball/game/game.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/game/components/components.dart'; import 'package:pinball_components/pinball_components.dart'; /// {@template sparky_scorch} @@ -16,17 +17,20 @@ class SparkyScorch extends Component { children: [ SparkyBumper.a( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(-22.9, -41.65), SparkyBumper.b( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(-21.25, -57.9), SparkyBumper.c( children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), + BumperNoisyBehavior(), ], )..initialPosition = Vector2(-3.3, -52.55), SparkyComputerSensor()..initialPosition = Vector2(-13, -49.9), @@ -47,7 +51,7 @@ class SparkyComputerSensor extends BodyComponent : super( renderBody: false, children: [ - ScoringBehavior(points: Points.twentyThousand), + ScoringContactBehavior(points: Points.twentyThousand), ], ); diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index 2a847ce0..ac324417 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -13,7 +13,6 @@ extension PinballGameAssetsX on PinballGame { return [ images.load(components.Assets.images.boardBackground.keyName), - images.load(components.Assets.images.ball.ball.keyName), images.load(components.Assets.images.ball.flameEffect.keyName), images.load(components.Assets.images.signpost.inactive.keyName), images.load(components.Assets.images.signpost.active1.keyName), @@ -99,8 +98,8 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.sparky.bumper.b.dimmed.keyName), images.load(components.Assets.images.sparky.bumper.c.lit.keyName), images.load(components.Assets.images.sparky.bumper.c.dimmed.keyName), - images.load(components.Assets.images.backboard.backboardScores.keyName), - images.load(components.Assets.images.backboard.backboardGameOver.keyName), + images.load(components.Assets.images.backbox.marquee.keyName), + images.load(components.Assets.images.backbox.displayDivider.keyName), images.load(components.Assets.images.googleWord.letter1.lit.keyName), images.load(components.Assets.images.googleWord.letter1.dimmed.keyName), images.load(components.Assets.images.googleWord.letter2.lit.keyName), @@ -113,7 +112,6 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.googleWord.letter5.dimmed.keyName), images.load(components.Assets.images.googleWord.letter6.lit.keyName), images.load(components.Assets.images.googleWord.letter6.dimmed.keyName), - images.load(components.Assets.images.backboard.display.keyName), images.load(components.Assets.images.multiball.lit.keyName), images.load(components.Assets.images.multiball.dimmed.keyName), images.load(components.Assets.images.multiplier.x2.lit.keyName), @@ -130,10 +128,21 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.score.twentyThousand.keyName), images.load(components.Assets.images.score.twoHundredThousand.keyName), images.load(components.Assets.images.score.oneMillion.keyName), + images.load(components.Assets.images.flapper.backSupport.keyName), + images.load(components.Assets.images.flapper.frontSupport.keyName), + images.load(components.Assets.images.flapper.flap.keyName), + images.load(components.Assets.images.skillShot.decal.keyName), + images.load(components.Assets.images.skillShot.pin.keyName), + images.load(components.Assets.images.skillShot.lit.keyName), + images.load(components.Assets.images.skillShot.dimmed.keyName), images.load(dashTheme.leaderboardIcon.keyName), images.load(sparkyTheme.leaderboardIcon.keyName), images.load(androidTheme.leaderboardIcon.keyName), images.load(dinoTheme.leaderboardIcon.keyName), + images.load(androidTheme.ball.keyName), + images.load(dashTheme.ball.keyName), + images.load(dinoTheme.ball.keyName), + images.load(sparkyTheme.ball.keyName), ]; } } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index f9018ee5..b022ea6a 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -5,24 +5,28 @@ import 'package:flame/components.dart'; import 'package:flame/game.dart'; import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_theme/pinball_theme.dart'; -class PinballGame extends Forge2DGame +class PinballGame extends PinballForge2DGame with FlameBloc, HasKeyboardHandlerComponents, Controls<_GameBallsController>, - TapDetector { + MultiTouchTapDetector { PinballGame({ required this.characterTheme, - required this.audio, + required this.leaderboardRepository, + required this.l10n, + required this.player, }) : super(gravity: Vector2(0, 30)) { images.prefix = ''; controller = _GameBallsController(this); @@ -36,24 +40,30 @@ class PinballGame extends Forge2DGame final CharacterTheme characterTheme; - final PinballAudio audio; + final PinballPlayer player; - late final GameFlowController gameFlowController; + final LeaderboardRepository leaderboardRepository; + + final AppLocalizations l10n; @override Future onLoad() async { - await add(gameFlowController = GameFlowController(this)); await add(CameraController(this)); final machine = [ BoardBackgroundSpriteComponent(), Boundaries(), - Backboard.waiting(position: Vector2(0, -88)), + Backbox(leaderboardRepository: leaderboardRepository), ]; final decals = [ GoogleWord(position: Vector2(-4.25, 1.8)), Multipliers(), Multiballs(), + SkillShot( + children: [ + ScoringContactBehavior(points: Points.oneMillion), + ], + ), ]; final characterAreas = [ AndroidAcres(), @@ -62,26 +72,38 @@ class PinballGame extends Forge2DGame SparkyScorch(), ]; - await add( - ZCanvasComponent( - children: [ - ...machine, - ...decals, - ...characterAreas, - Drain(), - BottomGroup(), - Launcher(), - ], - ), + await addAll( + [ + GameBlocStatusListener(), + CanvasComponent( + onSpritePainted: (paint) { + if (paint.filterQuality != FilterQuality.medium) { + paint.filterQuality = FilterQuality.medium; + } + }, + children: [ + ZCanvasComponent( + children: [ + ...machine, + ...decals, + ...characterAreas, + Drain(), + BottomGroup(), + Launcher(), + ], + ), + ], + ), + ], ); await super.onLoad(); } - BoardSide? focusedBoardSide; + final focusedBoardSide = {}; @override - void onTapDown(TapDownInfo info) { + void onTapDown(int pointerId, TapDownInfo info) { if (info.raw.kind == PointerDeviceKind.touch) { final rocket = descendants().whereType().first; final bounds = rocket.topLeftPosition & rocket.size; @@ -91,36 +113,37 @@ class PinballGame extends Forge2DGame descendants().whereType().single.pullFor(2); } else { final leftSide = info.eventPosition.widget.x < canvasSize.x / 2; - focusedBoardSide = leftSide ? BoardSide.left : BoardSide.right; + focusedBoardSide[pointerId] = + leftSide ? BoardSide.left : BoardSide.right; final flippers = descendants().whereType().where((flipper) { - return flipper.side == focusedBoardSide; + return flipper.side == focusedBoardSide[pointerId]; }); flippers.first.moveUp(); } } - super.onTapDown(info); + super.onTapDown(pointerId, info); } @override - void onTapUp(TapUpInfo info) { - _moveFlippersDown(); - super.onTapUp(info); + void onTapUp(int pointerId, TapUpInfo info) { + _moveFlippersDown(pointerId); + super.onTapUp(pointerId, info); } @override - void onTapCancel() { - _moveFlippersDown(); - super.onTapCancel(); + void onTapCancel(int pointerId) { + _moveFlippersDown(pointerId); + super.onTapCancel(pointerId); } - void _moveFlippersDown() { - if (focusedBoardSide != null) { + void _moveFlippersDown(int pointerId) { + if (focusedBoardSide[pointerId] != null) { final flippers = descendants().whereType().where((flipper) { - return flipper.side == focusedBoardSide; + return flipper.side == focusedBoardSide[pointerId]; }); flippers.first.moveDown(); - focusedBoardSide = null; + focusedBoardSide.remove(pointerId); } } } @@ -132,9 +155,7 @@ class _GameBallsController extends ComponentController @override bool listenWhen(GameState? previousState, GameState newState) { final noBallsLeft = component.descendants().whereType().isEmpty; - final notGameOver = !newState.isGameOver; - - return noBallsLeft && notGameOver; + return noBallsLeft && newState.status.isPlaying; } @override @@ -159,42 +180,100 @@ class _GameBallsController extends ComponentController plunger.body.position.x, plunger.body.position.y - Ball.size.y, ); - component.firstChild()?.add(ball); + component.descendants().whereType().single.add(ball); }); } } -class DebugPinballGame extends PinballGame with FPSCounter { +class DebugPinballGame extends PinballGame with FPSCounter, PanDetector { DebugPinballGame({ required CharacterTheme characterTheme, - required PinballAudio audio, + required LeaderboardRepository leaderboardRepository, + required AppLocalizations l10n, + required PinballPlayer player, }) : super( characterTheme: characterTheme, - audio: audio, + player: player, + leaderboardRepository: leaderboardRepository, + l10n: l10n, ) { controller = _GameBallsController(this); } + Vector2? lineStart; + Vector2? lineEnd; + @override Future onLoad() async { await super.onLoad(); + await add(PreviewLine()); + await add(_DebugInformation()); } @override - void onTapUp(TapUpInfo info) { - super.onTapUp(info); + void onTapUp(int pointerId, TapUpInfo info) { + super.onTapUp(pointerId, info); if (info.raw.kind == PointerDeviceKind.mouse) { + final canvas = descendants().whereType().single; final ball = ControlledBall.debug() ..initialPosition = info.eventPosition.game; - firstChild()?.add(ball); + canvas.add(ball); } } + + @override + void onPanStart(DragStartInfo info) { + lineStart = info.eventPosition.game; + } + + @override + void onPanUpdate(DragUpdateInfo info) { + lineEnd = info.eventPosition.game; + } + + @override + void onPanEnd(DragEndInfo info) { + if (lineEnd != null) { + final line = lineEnd! - lineStart!; + _turboChargeBall(line); + lineEnd = null; + lineStart = null; + } + } + + void _turboChargeBall(Vector2 line) { + final canvas = descendants().whereType().single; + final ball = ControlledBall.debug()..initialPosition = lineStart!; + final impulse = line * -1 * 10; + ball.add(BallTurboChargingBehavior(impulse: impulse)); + canvas.add(ball); + } } -// TODO(wolfenrain): investigate this CI failure. // coverage:ignore-start +class PreviewLine extends PositionComponent with HasGameRef { + static final _previewLinePaint = Paint() + ..color = Colors.pink + ..strokeWidth = 0.4 + ..style = PaintingStyle.stroke; + + @override + void render(Canvas canvas) { + super.render(canvas); + + if (gameRef.lineEnd != null) { + canvas.drawLine( + gameRef.lineStart!.toOffset(), + gameRef.lineEnd!.toOffset(), + _previewLinePaint, + ); + } + } +} + +// TODO(wolfenrain): investigate this CI failure. class _DebugInformation extends Component with HasGameRef { @override PositionType get positionType => PositionType.widget; diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 4557c243..31ba304b 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -4,8 +4,10 @@ import 'package:flame/game.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/assets_manager/assets_manager.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; @@ -35,23 +37,32 @@ class PinballGamePage extends StatelessWidget { Widget build(BuildContext context) { final characterTheme = context.read().state.characterTheme; - final audio = context.read(); - final pinballAudio = context.read(); + final player = context.read(); + final leaderboardRepository = context.read(); final game = isDebugMode - ? DebugPinballGame(characterTheme: characterTheme, audio: audio) - : PinballGame(characterTheme: characterTheme, audio: audio); + ? DebugPinballGame( + characterTheme: characterTheme, + player: player, + leaderboardRepository: leaderboardRepository, + l10n: context.l10n, + ) + : PinballGame( + characterTheme: characterTheme, + player: player, + leaderboardRepository: leaderboardRepository, + l10n: context.l10n, + ); final loadables = [ ...game.preLoadAssets(), - pinballAudio.load(), + ...player.load(), ...BonusAnimation.loadAssets(), ...SelectedCharacter.loadAssets(), ]; return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => StartGameBloc(game: game)), BlocProvider(create: (_) => GameBloc()), BlocProvider(create: (_) => AssetsManagerCubit(loadables)..load()), ], @@ -96,36 +107,43 @@ class PinballGameLoadedView extends StatelessWidget { @override Widget build(BuildContext context) { + final isPlaying = context.select( + (StartGameBloc bloc) => bloc.state.status == StartGameStatus.play, + ); final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; final screenWidth = MediaQuery.of(context).size.width; final leftMargin = (screenWidth / 2) - (gameWidgetWidth / 1.8); + final clampedMargin = leftMargin > 0 ? leftMargin : 0.0; - return Stack( - children: [ - Positioned.fill( - child: GameWidget( - game: game, - initialActiveOverlays: const [PinballGame.playButtonOverlay], - overlayBuilderMap: { - PinballGame.playButtonOverlay: (context, game) { - return Positioned( - bottom: 20, - right: 0, - left: 0, - child: PlayButtonOverlay(game: game), - ); + return StartGameListener( + child: Stack( + children: [ + Positioned.fill( + child: GameWidget( + game: game, + initialActiveOverlays: const [PinballGame.playButtonOverlay], + overlayBuilderMap: { + PinballGame.playButtonOverlay: (context, game) { + return const Positioned( + bottom: 20, + right: 0, + left: 0, + child: PlayButtonOverlay(), + ); + }, }, - }, + ), ), - ), - // TODO(arturplaczek): add Visibility to GameHud based on StartGameBloc - // status - Positioned( - top: 16, - left: leftMargin, - child: const GameHud(), - ), - ], + Positioned( + top: 0, + left: clampedMargin, + child: Visibility( + visible: isPlaying, + child: const GameHud(), + ), + ), + ], + ), ); } } diff --git a/lib/game/view/widgets/game_hud.dart b/lib/game/view/widgets/game_hud.dart index 605bceb4..5f651a60 100644 --- a/lib/game/view/widgets/game_hud.dart +++ b/lib/game/view/widgets/game_hud.dart @@ -7,8 +7,8 @@ import 'package:pinball_ui/pinball_ui.dart'; /// {@template game_hud} /// Overlay on the [PinballGame]. /// -/// Displays the current [GameState.score], [GameState.rounds] and animates when -/// the player gets a [GameBonus]. +/// Displays the current [GameState.displayScore], [GameState.rounds] and +/// animates when the player gets a [GameBonus]. /// {@endtemplate} class GameHud extends StatefulWidget { /// {@macro game_hud} @@ -23,16 +23,18 @@ class _GameHudState extends State { /// 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 isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver); + final isGameOver = + context.select((GameBloc bloc) => bloc.state.status.isGameOver); + + final height = _calculateHeight(context); return _ScoreViewDecoration( child: SizedBox( - height: _width / _ratio, - width: _width, + height: height, + width: height * _ratio, child: BlocListener( listenWhen: (previous, current) => previous.bonusHistory.length != current.bonusHistory.length, @@ -53,6 +55,17 @@ class _GameHudState extends State { ), ); } + + double _calculateHeight(BuildContext context) { + final height = MediaQuery.of(context).size.height * 0.09; + if (height > 90) { + return 90; + } else if (height < 60) { + return 60; + } else { + return height; + } + } } class _ScoreViewDecoration extends StatelessWidget { diff --git a/lib/game/view/widgets/play_button_overlay.dart b/lib/game/view/widgets/play_button_overlay.dart index 1d4a10fb..7a954c77 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: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_ui/pinball_ui.dart'; /// {@template play_button_overlay} @@ -9,13 +9,7 @@ import 'package:pinball_ui/pinball_ui.dart'; /// {@endtemplate} class PlayButtonOverlay extends StatelessWidget { /// {@macro play_button_overlay} - const PlayButtonOverlay({ - Key? key, - required PinballGame game, - }) : _game = game, - super(key: key); - - final PinballGame _game; + const PlayButtonOverlay({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -23,9 +17,8 @@ class PlayButtonOverlay extends StatelessWidget { return PinballButton( text: l10n.play, - onTap: () async { - _game.gameFlowController.start(); - await showCharacterSelectionDialog(context); + onTap: () { + context.read().add(const PlayTapped()); }, ); } diff --git a/lib/game/view/widgets/score_view.dart b/lib/game/view/widgets/score_view.dart index 1fe57eb1..66233598 100644 --- a/lib/game/view/widgets/score_view.dart +++ b/lib/game/view/widgets/score_view.dart @@ -13,12 +13,13 @@ class ScoreView extends StatelessWidget { @override Widget build(BuildContext context) { - final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver); + final isGameOver = + context.select((GameBloc bloc) => bloc.state.status.isGameOver); return Padding( padding: const EdgeInsets.symmetric( horizontal: 16, - vertical: 8, + vertical: 2, ), child: AnimatedSwitcher( duration: kThemeAnimationDuration, @@ -69,11 +70,13 @@ class _ScoreText extends StatelessWidget { @override Widget build(BuildContext context) { - final score = context.select((GameBloc bloc) => bloc.state.score); + final score = context.select((GameBloc bloc) => bloc.state.displayScore); - return Text( - score.formatScore(), - style: Theme.of(context).textTheme.headline1, + return FittedBox( + child: Text( + score.formatScore(), + style: Theme.of(context).textTheme.headline2, + ), ); } } diff --git a/lib/how_to_play/widgets/how_to_play_dialog.dart b/lib/how_to_play/widgets/how_to_play_dialog.dart index e91698f5..1fd26837 100644 --- a/lib/how_to_play/widgets/how_to_play_dialog.dart +++ b/lib/how_to_play/widgets/how_to_play_dialog.dart @@ -51,24 +51,16 @@ extension on Control { } } -Future showHowToPlayDialog(BuildContext context) { - final audio = context.read(); - return showDialog( - context: context, - builder: (_) => HowToPlayDialog(), - ).then((_) { - audio.ioPinballVoiceOver(); - }); -} - class HowToPlayDialog extends StatefulWidget { HowToPlayDialog({ Key? key, + required this.onDismissCallback, @visibleForTesting PlatformHelper? platformHelper, }) : platformHelper = platformHelper ?? PlatformHelper(), super(key: key); final PlatformHelper platformHelper; + final VoidCallback onDismissCallback; @override State createState() => _HowToPlayDialogState(); @@ -82,6 +74,7 @@ class _HowToPlayDialogState extends State { closeTimer = Timer(const Duration(seconds: 3), () { if (mounted) { Navigator.of(context).pop(); + widget.onDismissCallback.call(); } }); } @@ -96,10 +89,20 @@ class _HowToPlayDialogState extends State { Widget build(BuildContext context) { final isMobile = widget.platformHelper.isMobile; final l10n = context.l10n; - return PinballDialog( - title: l10n.howToPlay, - subtitle: l10n.tipsForFlips, - child: isMobile ? const _MobileBody() : const _DesktopBody(), + + return WillPopScope( + onWillPop: () { + widget.onDismissCallback.call(); + context.read().play(PinballAudio.ioPinballVoiceOver); + return Future.value(true); + }, + child: PinballDialog( + title: l10n.howToPlay, + subtitle: l10n.tipsForFlips, + child: FittedBox( + child: isMobile ? const _MobileBody() : const _DesktopBody(), + ), + ), ); } } @@ -111,18 +114,16 @@ class _MobileBody extends StatelessWidget { Widget build(BuildContext context) { final paddingWidth = MediaQuery.of(context).size.width * 0.15; final paddingHeight = MediaQuery.of(context).size.height * 0.075; - return FittedBox( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: paddingWidth, - ), - child: Column( - children: [ - const _MobileLaunchControls(), - SizedBox(height: paddingHeight), - const _MobileFlipperControls(), - ], - ), + return Padding( + padding: EdgeInsets.symmetric( + horizontal: paddingWidth, + ), + child: Column( + children: [ + const _MobileLaunchControls(), + SizedBox(height: paddingHeight), + const _MobileFlipperControls(), + ], ), ); } @@ -191,13 +192,15 @@ class _DesktopBody extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - children: const [ - SizedBox(height: 16), - _DesktopLaunchControls(), - SizedBox(height: 16), - _DesktopFlipperControls(), - ], + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: const [ + _DesktopLaunchControls(), + SizedBox(height: 16), + _DesktopFlipperControls(), + ], + ), ); } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5566066f..03fde0bd 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -64,49 +64,45 @@ "@gameOver": { "description": "Text displayed on the ending dialog when game finishes" }, - "leaderboard": "Leaderboard", - "@leaderboard": { - "description": "Text displayed on the ending dialog leaderboard button" + "rounds": "Ball Ct:", + "@rounds": { + "description": "Text displayed on the scoreboard widget to indicate rounds left" }, - "rank": "Rank", - "@rank": { - "description": "Text displayed on the leaderboard page header rank column" + "topPlayers": "Top Players", + "@topPlayers": { + "description": "Title text displayed on leaderboard screen" }, - "character": "Character", - "@character": { - "description": "Text displayed on the leaderboard page header character column" + "rank": "rank", + "@rank": { + "description": "Label text displayed above player's rank" }, - "username": "Username", - "@username": { - "description": "Text displayed on the leaderboard page header userName column" + "name": "name", + "@name": { + "description": "Label text displayed above player's initials" }, - "score": "Score", + "score": "score", "@score": { - "description": "Text displayed on the leaderboard page header score column" - }, - "retry": "Retry", - "@retry": { - "description": "Text displayed on the retry button leaders board page" + "description": "Label text displayed above player's score" }, - "addUser": "Add User", - "@addUser": { - "description": "Text displayed on the add user button at ending dialog" + "enterInitials": "Enter your initials using the", + "@enterInitials": { + "description": "Informational text displayed on initials input screen" }, - "error": "Error", - "@error": { - "description": "Text displayed on the ending dialog when there is any error on sending user" + "arrows": "arrows", + "@arrows": { + "description": "Text displayed on initials input screen indicating arrow keys" }, - "yourScore": "Your score is", - "@yourScore": { - "description": "Text displayed on the ending dialog when game finishes to show the final score" + "andPress": "and press", + "@andPress": { + "description": "Connecting text displayed on initials input screen informational text span" }, - "enterInitials": "Enter your initials", - "@enterInitials": { - "description": "Text displayed on the ending dialog when game finishes to ask the user for his initials" + "enterReturn": "enter/return", + "@enterReturn": { + "description": "Text displayed on initials input screen indicating return key" }, - "rounds": "Ball Ct:", - "@rounds": { - "description": "Text displayed on the scoreboard widget to indicate rounds left" + "toSubmit": "to submit", + "@toSubmit": { + "description": "Ending text displayed on initials input screen informational text span" }, "footerMadeWithText": "Made with ", "@footerMadeWithText": { diff --git a/lib/leaderboard/models/leader_board_entry.dart b/lib/leaderboard/models/leader_board_entry.dart index a86975dd..db4980a1 100644 --- a/lib/leaderboard/models/leader_board_entry.dart +++ b/lib/leaderboard/models/leader_board_entry.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball_theme/pinball_theme.dart'; @@ -6,9 +7,9 @@ import 'package:pinball_theme/pinball_theme.dart'; /// player's initials, score, and chosen character. /// /// {@endtemplate} -class LeaderboardEntry { +class LeaderboardEntry extends Equatable { /// {@macro leaderboard_entry} - LeaderboardEntry({ + const LeaderboardEntry({ required this.rank, required this.playerInitials, required this.score, @@ -26,6 +27,9 @@ class LeaderboardEntry { /// [CharacterTheme] for [LeaderboardEntry]. final AssetGenImage character; + + @override + List get props => [rank, playerInitials, score, character]; } /// Converts [LeaderboardEntryData] from repository to [LeaderboardEntry]. diff --git a/lib/main_development.dart b/lib/main_development.dart index 21166057..67d83b81 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -11,7 +11,7 @@ void main() { bootstrap((firestore, firebaseAuth) async { final leaderboardRepository = LeaderboardRepository(firestore); final authenticationRepository = AuthenticationRepository(firebaseAuth); - final pinballAudio = PinballAudio(); + final pinballPlayer = PinballPlayer(); unawaited( Firebase.initializeApp().then( (_) => authenticationRepository.authenticateAnonymously(), @@ -20,7 +20,7 @@ void main() { return App( authenticationRepository: authenticationRepository, leaderboardRepository: leaderboardRepository, - pinballAudio: pinballAudio, + pinballPlayer: pinballPlayer, ); }); } diff --git a/lib/main_production.dart b/lib/main_production.dart index 21166057..67d83b81 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -11,7 +11,7 @@ void main() { bootstrap((firestore, firebaseAuth) async { final leaderboardRepository = LeaderboardRepository(firestore); final authenticationRepository = AuthenticationRepository(firebaseAuth); - final pinballAudio = PinballAudio(); + final pinballPlayer = PinballPlayer(); unawaited( Firebase.initializeApp().then( (_) => authenticationRepository.authenticateAnonymously(), @@ -20,7 +20,7 @@ void main() { return App( authenticationRepository: authenticationRepository, leaderboardRepository: leaderboardRepository, - pinballAudio: pinballAudio, + pinballPlayer: pinballPlayer, ); }); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 21166057..67d83b81 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -11,7 +11,7 @@ void main() { bootstrap((firestore, firebaseAuth) async { final leaderboardRepository = LeaderboardRepository(firestore); final authenticationRepository = AuthenticationRepository(firebaseAuth); - final pinballAudio = PinballAudio(); + final pinballPlayer = PinballPlayer(); unawaited( Firebase.initializeApp().then( (_) => authenticationRepository.authenticateAnonymously(), @@ -20,7 +20,7 @@ void main() { return App( authenticationRepository: authenticationRepository, leaderboardRepository: leaderboardRepository, - pinballAudio: pinballAudio, + pinballPlayer: pinballPlayer, ); }); } diff --git a/lib/select_character/view/character_selection_page.dart b/lib/select_character/view/character_selection_page.dart index 3b00829b..1f7b0374 100644 --- a/lib/select_character/view/character_selection_page.dart +++ b/lib/select_character/view/character_selection_page.dart @@ -1,20 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:pinball/how_to_play/how_to_play.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/pinball_theme.dart'; import 'package:pinball_ui/pinball_ui.dart'; -/// Inflates [CharacterSelectionDialog] using [showDialog]. -Future showCharacterSelectionDialog(BuildContext context) { - return showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const CharacterSelectionDialog(), - ); -} - /// {@template character_selection_dialog} /// Dialog used to select the playing character of the game. /// {@endtemplate character_selection_dialog} @@ -59,7 +50,7 @@ class _SelectCharacterButton extends StatelessWidget { return PinballButton( onTap: () async { Navigator.of(context).pop(); - await showHowToPlayDialog(context); + context.read().add(const CharacterSelected()); }, text: l10n.select, ); @@ -74,36 +65,40 @@ class _CharacterGrid extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Column( - children: [ - _Character( - key: const Key('sparky_character_selection'), - character: const SparkyTheme(), - isSelected: state.isSparkySelected, - ), - const SizedBox(height: 6), - _Character( - key: const Key('android_character_selection'), - character: const AndroidTheme(), - isSelected: state.isAndroidSelected, - ), - ], + Expanded( + child: Column( + children: [ + _Character( + key: const Key('sparky_character_selection'), + character: const SparkyTheme(), + isSelected: state.isSparkySelected, + ), + const SizedBox(height: 6), + _Character( + key: const Key('android_character_selection'), + character: const AndroidTheme(), + isSelected: state.isAndroidSelected, + ), + ], + ), ), const SizedBox(width: 6), - Column( - children: [ - _Character( - key: const Key('dash_character_selection'), - character: const DashTheme(), - isSelected: state.isDashSelected, - ), - const SizedBox(height: 6), - _Character( - key: const Key('dino_character_selection'), - character: const DinoTheme(), - isSelected: state.isDinoSelected, - ), - ], + Expanded( + child: Column( + children: [ + _Character( + key: const Key('dash_character_selection'), + character: const DashTheme(), + isSelected: state.isDashSelected, + ), + const SizedBox(height: 6), + _Character( + key: const Key('dino_character_selection'), + character: const DinoTheme(), + isSelected: state.isDinoSelected, + ), + ], + ), ), ], ); diff --git a/lib/start_game/bloc/start_game_bloc.dart b/lib/start_game/bloc/start_game_bloc.dart index ba44d88c..3a96b57b 100644 --- a/lib/start_game/bloc/start_game_bloc.dart +++ b/lib/start_game/bloc/start_game_bloc.dart @@ -1,6 +1,5 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:pinball/game/game.dart'; part 'start_game_event.dart'; part 'start_game_state.dart'; @@ -10,23 +9,16 @@ part 'start_game_state.dart'; /// {@endtemplate} class StartGameBloc extends Bloc { /// {@macro start_game_bloc} - StartGameBloc({ - required PinballGame game, - }) : _game = game, - super(const StartGameState.initial()) { + StartGameBloc() : super(const StartGameState.initial()) { on(_onPlayTapped); on(_onCharacterSelected); on(_onHowToPlayFinished); } - final PinballGame _game; - void _onPlayTapped( PlayTapped event, Emitter emit, ) { - _game.gameFlowController.start(); - emit( state.copyWith( status: StartGameStatus.selectCharacter, diff --git a/lib/start_game/start_game.dart b/lib/start_game/start_game.dart index 7171c66d..9e63b170 100644 --- a/lib/start_game/start_game.dart +++ b/lib/start_game/start_game.dart @@ -1 +1,2 @@ export 'bloc/start_game_bloc.dart'; +export 'widgets/start_game_listener.dart'; diff --git a/lib/start_game/widgets/start_game_listener.dart b/lib/start_game/widgets/start_game_listener.dart new file mode 100644 index 00000000..692116f3 --- /dev/null +++ b/lib/start_game/widgets/start_game_listener.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/how_to_play/how_to_play.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +/// {@template start_game_listener} +/// Listener that manages the display of dialogs for [StartGameStatus]. +/// +/// It's responsible for starting the game after pressing play button +/// and playing a sound after the 'how to play' dialog. +/// {@endtemplate} +class StartGameListener extends StatelessWidget { + /// {@macro start_game_listener} + const StartGameListener({ + Key? key, + required Widget child, + }) : _child = child, + super(key: key); + + final Widget _child; + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + switch (state.status) { + case StartGameStatus.initial: + break; + case StartGameStatus.selectCharacter: + _onSelectCharacter(context); + context.read().add(const GameStarted()); + break; + case StartGameStatus.howToPlay: + _onHowToPlay(context); + break; + case StartGameStatus.play: + break; + } + }, + child: _child, + ); + } + + void _onSelectCharacter(BuildContext context) { + _showPinballDialog( + context: context, + child: const CharacterSelectionDialog(), + barrierDismissible: false, + ); + } + + void _onHowToPlay(BuildContext context) { + _showPinballDialog( + context: context, + child: HowToPlayDialog( + onDismissCallback: () { + context.read().add(const HowToPlayFinished()); + }, + ), + ); + } + + void _showPinballDialog({ + required BuildContext context, + required Widget child, + bool barrierDismissible = true, + }) { + final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; + + showDialog( + context: context, + barrierColor: PinballColors.transparent, + barrierDismissible: barrierDismissible, + builder: (_) { + return Center( + child: SizedBox( + height: gameWidgetWidth * 0.87, + width: gameWidgetWidth, + child: child, + ), + ); + }, + ); + } +} diff --git a/packages/pinball_audio/assets/sfx/bumper_a.mp3 b/packages/pinball_audio/assets/sfx/bumper_a.mp3 new file mode 100644 index 00000000..76c0b022 Binary files /dev/null and b/packages/pinball_audio/assets/sfx/bumper_a.mp3 differ diff --git a/packages/pinball_audio/assets/sfx/bumper_b.mp3 b/packages/pinball_audio/assets/sfx/bumper_b.mp3 new file mode 100644 index 00000000..e409a018 Binary files /dev/null and b/packages/pinball_audio/assets/sfx/bumper_b.mp3 differ diff --git a/packages/pinball_audio/assets/sfx/plim.mp3 b/packages/pinball_audio/assets/sfx/plim.mp3 deleted file mode 100644 index a726024d..00000000 Binary files a/packages/pinball_audio/assets/sfx/plim.mp3 and /dev/null differ diff --git a/packages/pinball_audio/lib/gen/assets.gen.dart b/packages/pinball_audio/lib/gen/assets.gen.dart index 0f68e170..5bb8fea8 100644 --- a/packages/pinball_audio/lib/gen/assets.gen.dart +++ b/packages/pinball_audio/lib/gen/assets.gen.dart @@ -14,9 +14,10 @@ class $AssetsMusicGen { class $AssetsSfxGen { const $AssetsSfxGen(); + String get bumperA => 'assets/sfx/bumper_a.mp3'; + String get bumperB => 'assets/sfx/bumper_b.mp3'; String get google => 'assets/sfx/google.mp3'; String get ioPinballVoiceOver => 'assets/sfx/io_pinball_voice_over.mp3'; - String get plim => 'assets/sfx/plim.mp3'; } class Assets { diff --git a/packages/pinball_audio/lib/src/pinball_audio.dart b/packages/pinball_audio/lib/src/pinball_audio.dart index f87b05d1..a859a86f 100644 --- a/packages/pinball_audio/lib/src/pinball_audio.dart +++ b/packages/pinball_audio/lib/src/pinball_audio.dart @@ -1,10 +1,27 @@ +import 'dart:math'; + import 'package:audioplayers/audioplayers.dart'; import 'package:flame_audio/audio_pool.dart'; import 'package:flame_audio/flame_audio.dart'; +import 'package:flutter/material.dart'; import 'package:pinball_audio/gen/assets.gen.dart'; -/// Function that defines the contract of the creation -/// of an [AudioPool] +/// Sounds available for play +enum PinballAudio { + /// Google + google, + + /// Bumper + bumper, + + /// Background music + backgroundMusic, + + /// IO Pinball voice over + ioPinballVoiceOver +} + +/// Defines the contract of the creation of an [AudioPool]. typedef CreateAudioPool = Future Function( String sound, { bool? repeating, @@ -29,17 +46,103 @@ typedef PreCacheSingleAudio = Future Function(String); /// an [AudioCache] instance typedef ConfigureAudioCache = void Function(AudioCache); -/// {@template pinball_audio} +abstract class _Audio { + void play(); + Future load(); + + String prefixFile(String file) { + return 'packages/pinball_audio/$file'; + } +} + +class _SimplePlayAudio extends _Audio { + _SimplePlayAudio({ + required this.preCacheSingleAudio, + required this.playSingleAudio, + required this.path, + }); + + final PreCacheSingleAudio preCacheSingleAudio; + final PlaySingleAudio playSingleAudio; + final String path; + + @override + Future load() => preCacheSingleAudio(prefixFile(path)); + + @override + void play() { + playSingleAudio(prefixFile(path)); + } +} + +class _LoopAudio extends _Audio { + _LoopAudio({ + required this.preCacheSingleAudio, + required this.loopSingleAudio, + required this.path, + }); + + final PreCacheSingleAudio preCacheSingleAudio; + final LoopSingleAudio loopSingleAudio; + final String path; + + @override + Future load() => preCacheSingleAudio(prefixFile(path)); + + @override + void play() { + loopSingleAudio(prefixFile(path)); + } +} + +class _BumperAudio extends _Audio { + _BumperAudio({ + required this.createAudioPool, + required this.seed, + }); + + final CreateAudioPool createAudioPool; + final Random seed; + + late AudioPool bumperA; + late AudioPool bumperB; + + @override + Future load() async { + await Future.wait( + [ + createAudioPool( + prefixFile(Assets.sfx.bumperA), + maxPlayers: 4, + prefix: '', + ).then((pool) => bumperA = pool), + createAudioPool( + prefixFile(Assets.sfx.bumperB), + maxPlayers: 4, + prefix: '', + ).then((pool) => bumperB = pool), + ], + ); + } + + @override + void play() { + (seed.nextBool() ? bumperA : bumperB).start(volume: 0.6); + } +} + +/// {@template pinball_player} /// Sound manager for the pinball game /// {@endtemplate} -class PinballAudio { - /// {@macro pinball_audio} - PinballAudio({ +class PinballPlayer { + /// {@macro pinball_player} + PinballPlayer({ CreateAudioPool? createAudioPool, PlaySingleAudio? playSingleAudio, LoopSingleAudio? loopSingleAudio, PreCacheSingleAudio? preCacheSingleAudio, ConfigureAudioCache? configureAudioCache, + Random? seed, }) : _createAudioPool = createAudioPool ?? AudioPool.create, _playSingleAudio = playSingleAudio ?? FlameAudio.audioCache.play, _loopSingleAudio = loopSingleAudio ?? FlameAudio.audioCache.loop, @@ -48,7 +151,30 @@ class PinballAudio { _configureAudioCache = configureAudioCache ?? ((AudioCache a) { a.prefix = ''; - }); + }), + _seed = seed ?? Random() { + audios = { + PinballAudio.google: _SimplePlayAudio( + preCacheSingleAudio: _preCacheSingleAudio, + playSingleAudio: _playSingleAudio, + path: Assets.sfx.google, + ), + PinballAudio.ioPinballVoiceOver: _SimplePlayAudio( + preCacheSingleAudio: _preCacheSingleAudio, + playSingleAudio: _playSingleAudio, + path: Assets.sfx.ioPinballVoiceOver, + ), + PinballAudio.bumper: _BumperAudio( + createAudioPool: _createAudioPool, + seed: _seed, + ), + PinballAudio.backgroundMusic: _LoopAudio( + preCacheSingleAudio: _preCacheSingleAudio, + loopSingleAudio: _loopSingleAudio, + path: Assets.music.background, + ), + }; + } final CreateAudioPool _createAudioPool; @@ -60,46 +186,26 @@ class PinballAudio { final ConfigureAudioCache _configureAudioCache; - late AudioPool _scorePool; + final Random _seed; + + /// Registered audios on the Player + @visibleForTesting + // ignore: library_private_types_in_public_api + late final Map audios; /// Loads the sounds effects into the memory - Future load() async { + List> load() { _configureAudioCache(FlameAudio.audioCache); - _scorePool = await _createAudioPool( - _prefixFile(Assets.sfx.plim), - maxPlayers: 4, - prefix: '', - ); - - await Future.wait([ - _preCacheSingleAudio(_prefixFile(Assets.sfx.google)), - _preCacheSingleAudio(_prefixFile(Assets.sfx.ioPinballVoiceOver)), - _preCacheSingleAudio(_prefixFile(Assets.music.background)), - ]); - } - - /// Plays the basic score sound - void score() { - _scorePool.start(); - } - - /// Plays the google word bonus - void googleBonus() { - _playSingleAudio(_prefixFile(Assets.sfx.google)); + return audios.values.map((a) => a.load()).toList(); } - /// Plays the I/O Pinball voice over audio. - void ioPinballVoiceOver() { - _playSingleAudio(_prefixFile(Assets.sfx.ioPinballVoiceOver)); - } - - /// Plays the background music - void backgroundMusic() { - _loopSingleAudio(_prefixFile(Assets.music.background)); - } - - String _prefixFile(String file) { - return 'packages/pinball_audio/$file'; + /// Plays the received auido + void play(PinballAudio audio) { + assert( + audios.containsKey(audio), + 'Tried to play unregistered audio $audio', + ); + audios[audio]?.play(); } } diff --git a/packages/pinball_audio/test/src/pinball_audio_test.dart b/packages/pinball_audio/test/src/pinball_audio_test.dart index 9d6dff98..8740cda0 100644 --- a/packages/pinball_audio/test/src/pinball_audio_test.dart +++ b/packages/pinball_audio/test/src/pinball_audio_test.dart @@ -1,4 +1,6 @@ // ignore_for_file: prefer_const_constructors, one_member_abstracts +import 'dart:math'; + import 'package:audioplayers/audioplayers.dart'; import 'package:flame_audio/audio_pool.dart'; import 'package:flame_audio/flame_audio.dart'; @@ -39,6 +41,8 @@ abstract class _PreCacheSingleAudio { class _MockPreCacheSingleAudio extends Mock implements _PreCacheSingleAudio {} +class _MockRandom extends Mock implements Random {} + void main() { group('PinballAudio', () { late _MockCreateAudioPool createAudioPool; @@ -46,7 +50,8 @@ void main() { late _MockPlaySingleAudio playSingleAudio; late _MockLoopSingleAudio loopSingleAudio; late _PreCacheSingleAudio preCacheSingleAudio; - late PinballAudio audio; + late Random seed; + late PinballPlayer player; setUpAll(() { registerFallbackValue(_MockAudioCache()); @@ -74,26 +79,37 @@ void main() { preCacheSingleAudio = _MockPreCacheSingleAudio(); when(() => preCacheSingleAudio.onCall(any())).thenAnswer((_) async {}); - audio = PinballAudio( + seed = _MockRandom(); + + player = PinballPlayer( configureAudioCache: configureAudioCache.onCall, createAudioPool: createAudioPool.onCall, playSingleAudio: playSingleAudio.onCall, loopSingleAudio: loopSingleAudio.onCall, preCacheSingleAudio: preCacheSingleAudio.onCall, + seed: seed, ); }); test('can be instantiated', () { - expect(PinballAudio(), isNotNull); + expect(PinballPlayer(), isNotNull); }); group('load', () { - test('creates the score pool', () async { - await audio.load(); + test('creates the bumpers pools', () async { + await Future.wait(player.load()); + + verify( + () => createAudioPool.onCall( + 'packages/pinball_audio/${Assets.sfx.bumperA}', + maxPlayers: 4, + prefix: '', + ), + ).called(1); verify( () => createAudioPool.onCall( - 'packages/pinball_audio/${Assets.sfx.plim}', + 'packages/pinball_audio/${Assets.sfx.bumperB}', maxPlayers: 4, prefix: '', ), @@ -101,25 +117,25 @@ void main() { }); test('configures the audio cache instance', () async { - await audio.load(); + await Future.wait(player.load()); verify(() => configureAudioCache.onCall(FlameAudio.audioCache)) .called(1); }); test('sets the correct prefix', () async { - audio = PinballAudio( + player = PinballPlayer( createAudioPool: createAudioPool.onCall, playSingleAudio: playSingleAudio.onCall, preCacheSingleAudio: preCacheSingleAudio.onCall, ); - await audio.load(); + await Future.wait(player.load()); expect(FlameAudio.audioCache.prefix, equals('')); }); test('pre cache the assets', () async { - await audio.load(); + await Future.wait(player.load()); verify( () => preCacheSingleAudio @@ -137,29 +153,59 @@ void main() { }); }); - group('score', () { - test('plays the score sound pool', () async { - final audioPool = _MockAudioPool(); - when(audioPool.start).thenAnswer((_) async => () {}); + group('bumper', () { + late AudioPool bumperAPool; + late AudioPool bumperBPool; + + setUp(() { + bumperAPool = _MockAudioPool(); + when(() => bumperAPool.start(volume: any(named: 'volume'))) + .thenAnswer((_) async => () {}); when( () => createAudioPool.onCall( - any(), + 'packages/pinball_audio/${Assets.sfx.bumperA}', maxPlayers: any(named: 'maxPlayers'), prefix: any(named: 'prefix'), ), - ).thenAnswer((_) async => audioPool); + ).thenAnswer((_) async => bumperAPool); - await audio.load(); - audio.score(); + bumperBPool = _MockAudioPool(); + when(() => bumperBPool.start(volume: any(named: 'volume'))) + .thenAnswer((_) async => () {}); + when( + () => createAudioPool.onCall( + 'packages/pinball_audio/${Assets.sfx.bumperB}', + maxPlayers: any(named: 'maxPlayers'), + prefix: any(named: 'prefix'), + ), + ).thenAnswer((_) async => bumperBPool); + }); + + group('when seed is true', () { + test('plays the bumper A sound pool', () async { + when(seed.nextBool).thenReturn(true); + await Future.wait(player.load()); + player.play(PinballAudio.bumper); - verify(audioPool.start).called(1); + verify(() => bumperAPool.start(volume: 0.6)).called(1); + }); + }); + + group('when seed is false', () { + test('plays the bumper B sound pool', () async { + when(seed.nextBool).thenReturn(false); + await Future.wait(player.load()); + player.play(PinballAudio.bumper); + + verify(() => bumperBPool.start(volume: 0.6)).called(1); + }); }); }); group('googleBonus', () { test('plays the correct file', () async { - await audio.load(); - audio.googleBonus(); + await Future.wait(player.load()); + player.play(PinballAudio.google); verify( () => playSingleAudio @@ -170,8 +216,8 @@ void main() { group('ioPinballVoiceOver', () { test('plays the correct file', () async { - await audio.load(); - audio.ioPinballVoiceOver(); + await Future.wait(player.load()); + player.play(PinballAudio.ioPinballVoiceOver); verify( () => playSingleAudio.onCall( @@ -183,8 +229,8 @@ void main() { group('backgroundMusic', () { test('plays the correct file', () async { - await audio.load(); - audio.backgroundMusic(); + await Future.wait(player.load()); + player.play(PinballAudio.backgroundMusic); verify( () => loopSingleAudio @@ -192,5 +238,15 @@ void main() { ).called(1); }); }); + + test( + 'throws assertions error when playing an unregistered audio', + () async { + player.audios.remove(PinballAudio.google); + await Future.wait(player.load()); + + expect(() => player.play(PinballAudio.google), throwsAssertionError); + }, + ); }); } diff --git a/packages/pinball_components/assets/images/android/bumper/cow/dimmed.png b/packages/pinball_components/assets/images/android/bumper/cow/dimmed.png index 6a8bb146..4fc09ce6 100644 Binary files a/packages/pinball_components/assets/images/android/bumper/cow/dimmed.png and b/packages/pinball_components/assets/images/android/bumper/cow/dimmed.png differ diff --git a/packages/pinball_components/assets/images/android/bumper/cow/lit.png b/packages/pinball_components/assets/images/android/bumper/cow/lit.png index 4909708b..50223a50 100644 Binary files a/packages/pinball_components/assets/images/android/bumper/cow/lit.png and b/packages/pinball_components/assets/images/android/bumper/cow/lit.png differ diff --git a/packages/pinball_components/assets/images/android/spaceship/animatronic.png b/packages/pinball_components/assets/images/android/spaceship/animatronic.png index d4b165f3..733e794f 100644 Binary files a/packages/pinball_components/assets/images/android/spaceship/animatronic.png and b/packages/pinball_components/assets/images/android/spaceship/animatronic.png differ diff --git a/packages/pinball_components/assets/images/android/spaceship/saucer.png b/packages/pinball_components/assets/images/android/spaceship/saucer.png index 6c77525a..ff80f99c 100644 Binary files a/packages/pinball_components/assets/images/android/spaceship/saucer.png and b/packages/pinball_components/assets/images/android/spaceship/saucer.png differ diff --git a/packages/pinball_components/assets/images/backboard/backboard_game_over.png b/packages/pinball_components/assets/images/backboard/backboard_game_over.png deleted file mode 100644 index 70bd4544..00000000 Binary files a/packages/pinball_components/assets/images/backboard/backboard_game_over.png and /dev/null differ diff --git a/packages/pinball_components/assets/images/backboard/backboard_scores.png b/packages/pinball_components/assets/images/backboard/backboard_scores.png deleted file mode 100644 index dab850d2..00000000 Binary files a/packages/pinball_components/assets/images/backboard/backboard_scores.png and /dev/null differ diff --git a/packages/pinball_components/assets/images/backboard/display.png b/packages/pinball_components/assets/images/backboard/display.png deleted file mode 100644 index 97dbb50b..00000000 Binary files a/packages/pinball_components/assets/images/backboard/display.png and /dev/null differ diff --git a/packages/pinball_components/assets/images/backbox/display-divider.png b/packages/pinball_components/assets/images/backbox/display-divider.png new file mode 100644 index 00000000..c7be2066 Binary files /dev/null and b/packages/pinball_components/assets/images/backbox/display-divider.png differ diff --git a/packages/pinball_components/assets/images/backbox/marquee.png b/packages/pinball_components/assets/images/backbox/marquee.png new file mode 100644 index 00000000..ee98a495 Binary files /dev/null and b/packages/pinball_components/assets/images/backbox/marquee.png differ diff --git a/packages/pinball_components/assets/images/ball/ball.png b/packages/pinball_components/assets/images/ball/ball.png deleted file mode 100644 index 43332c9a..00000000 Binary files a/packages/pinball_components/assets/images/ball/ball.png and /dev/null differ diff --git a/packages/pinball_components/assets/images/boundary/outer.png b/packages/pinball_components/assets/images/boundary/outer.png index 1f3bab69..75ccdb6b 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/dash/bumper/a/active.png b/packages/pinball_components/assets/images/dash/bumper/a/active.png index 57330eb4..bd37498d 100644 Binary files a/packages/pinball_components/assets/images/dash/bumper/a/active.png and b/packages/pinball_components/assets/images/dash/bumper/a/active.png differ diff --git a/packages/pinball_components/assets/images/dash/bumper/a/inactive.png b/packages/pinball_components/assets/images/dash/bumper/a/inactive.png index bd37498d..57330eb4 100644 Binary files a/packages/pinball_components/assets/images/dash/bumper/a/inactive.png and b/packages/pinball_components/assets/images/dash/bumper/a/inactive.png differ diff --git a/packages/pinball_components/assets/images/dash/bumper/b/active.png b/packages/pinball_components/assets/images/dash/bumper/b/active.png index fe871847..81cd775a 100644 Binary files a/packages/pinball_components/assets/images/dash/bumper/b/active.png and b/packages/pinball_components/assets/images/dash/bumper/b/active.png differ diff --git a/packages/pinball_components/assets/images/dash/bumper/b/inactive.png b/packages/pinball_components/assets/images/dash/bumper/b/inactive.png index 81cd775a..fe871847 100644 Binary files a/packages/pinball_components/assets/images/dash/bumper/b/inactive.png and b/packages/pinball_components/assets/images/dash/bumper/b/inactive.png differ diff --git a/packages/pinball_components/assets/images/dash/bumper/main/active.png b/packages/pinball_components/assets/images/dash/bumper/main/active.png index 9508b56c..51df02ee 100644 Binary files a/packages/pinball_components/assets/images/dash/bumper/main/active.png and b/packages/pinball_components/assets/images/dash/bumper/main/active.png differ diff --git a/packages/pinball_components/assets/images/dash/bumper/main/inactive.png b/packages/pinball_components/assets/images/dash/bumper/main/inactive.png index 51df02ee..9508b56c 100644 Binary files a/packages/pinball_components/assets/images/dash/bumper/main/inactive.png and b/packages/pinball_components/assets/images/dash/bumper/main/inactive.png differ diff --git a/packages/pinball_components/assets/images/flapper/back-support.png b/packages/pinball_components/assets/images/flapper/back-support.png new file mode 100644 index 00000000..74b3ae84 Binary files /dev/null and b/packages/pinball_components/assets/images/flapper/back-support.png differ diff --git a/packages/pinball_components/assets/images/flapper/flap.png b/packages/pinball_components/assets/images/flapper/flap.png new file mode 100644 index 00000000..3860df17 Binary files /dev/null and b/packages/pinball_components/assets/images/flapper/flap.png differ diff --git a/packages/pinball_components/assets/images/flapper/front-support.png b/packages/pinball_components/assets/images/flapper/front-support.png new file mode 100644 index 00000000..c3b7ca1e Binary files /dev/null and b/packages/pinball_components/assets/images/flapper/front-support.png differ diff --git a/packages/pinball_components/assets/images/plunger/rocket.png b/packages/pinball_components/assets/images/plunger/rocket.png index bef65ea1..a8f89152 100644 Binary files a/packages/pinball_components/assets/images/plunger/rocket.png and b/packages/pinball_components/assets/images/plunger/rocket.png differ diff --git a/packages/pinball_components/assets/images/skill_shot/decal.png b/packages/pinball_components/assets/images/skill_shot/decal.png new file mode 100644 index 00000000..120d70aa Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/decal.png differ diff --git a/packages/pinball_components/assets/images/skill_shot/dimmed.png b/packages/pinball_components/assets/images/skill_shot/dimmed.png new file mode 100644 index 00000000..7cc32bd4 Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/dimmed.png differ diff --git a/packages/pinball_components/assets/images/skill_shot/lit.png b/packages/pinball_components/assets/images/skill_shot/lit.png new file mode 100644 index 00000000..d1bce99b Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/lit.png differ diff --git a/packages/pinball_components/assets/images/skill_shot/pin.png b/packages/pinball_components/assets/images/skill_shot/pin.png new file mode 100644 index 00000000..5b64e1ab Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/pin.png differ diff --git a/packages/pinball_components/assets/images/sparky/computer/glow.png b/packages/pinball_components/assets/images/sparky/computer/glow.png index 07ffdb0c..4386dfba 100644 Binary files a/packages/pinball_components/assets/images/sparky/computer/glow.png and b/packages/pinball_components/assets/images/sparky/computer/glow.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index da9747c4..cac04cc0 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -11,7 +11,7 @@ class $AssetsImagesGen { const $AssetsImagesGen(); $AssetsImagesAndroidGen get android => const $AssetsImagesAndroidGen(); - $AssetsImagesBackboardGen get backboard => const $AssetsImagesBackboardGen(); + $AssetsImagesBackboxGen get backbox => const $AssetsImagesBackboxGen(); $AssetsImagesBallGen get ball => const $AssetsImagesBallGen(); $AssetsImagesBaseboardGen get baseboard => const $AssetsImagesBaseboardGen(); @@ -22,6 +22,7 @@ class $AssetsImagesGen { $AssetsImagesBoundaryGen get boundary => const $AssetsImagesBoundaryGen(); $AssetsImagesDashGen get dash => const $AssetsImagesDashGen(); $AssetsImagesDinoGen get dino => const $AssetsImagesDinoGen(); + $AssetsImagesFlapperGen get flapper => const $AssetsImagesFlapperGen(); $AssetsImagesFlipperGen get flipper => const $AssetsImagesFlipperGen(); $AssetsImagesGoogleWordGen get googleWord => const $AssetsImagesGoogleWordGen(); @@ -34,6 +35,7 @@ class $AssetsImagesGen { $AssetsImagesPlungerGen get plunger => const $AssetsImagesPlungerGen(); $AssetsImagesScoreGen get score => const $AssetsImagesScoreGen(); $AssetsImagesSignpostGen get signpost => const $AssetsImagesSignpostGen(); + $AssetsImagesSkillShotGen get skillShot => const $AssetsImagesSkillShotGen(); $AssetsImagesSlingshotGen get slingshot => const $AssetsImagesSlingshotGen(); $AssetsImagesSparkyGen get sparky => const $AssetsImagesSparkyGen(); } @@ -49,20 +51,16 @@ class $AssetsImagesAndroidGen { const $AssetsImagesAndroidSpaceshipGen(); } -class $AssetsImagesBackboardGen { - const $AssetsImagesBackboardGen(); +class $AssetsImagesBackboxGen { + const $AssetsImagesBackboxGen(); - /// File path: assets/images/backboard/backboard_game_over.png - AssetGenImage get backboardGameOver => - const AssetGenImage('assets/images/backboard/backboard_game_over.png'); + /// File path: assets/images/backbox/display-divider.png + AssetGenImage get displayDivider => + const AssetGenImage('assets/images/backbox/display-divider.png'); - /// File path: assets/images/backboard/backboard_scores.png - AssetGenImage get backboardScores => - const AssetGenImage('assets/images/backboard/backboard_scores.png'); - - /// File path: assets/images/backboard/display.png - AssetGenImage get display => - const AssetGenImage('assets/images/backboard/display.png'); + /// File path: assets/images/backbox/marquee.png + AssetGenImage get marquee => + const AssetGenImage('assets/images/backbox/marquee.png'); } class $AssetsImagesBallGen { @@ -133,6 +131,22 @@ class $AssetsImagesDinoGen { const AssetGenImage('assets/images/dino/top-wall.png'); } +class $AssetsImagesFlapperGen { + const $AssetsImagesFlapperGen(); + + /// File path: assets/images/flapper/back-support.png + AssetGenImage get backSupport => + const AssetGenImage('assets/images/flapper/back-support.png'); + + /// File path: assets/images/flapper/flap.png + AssetGenImage get flap => + const AssetGenImage('assets/images/flapper/flap.png'); + + /// File path: assets/images/flapper/front-support.png + AssetGenImage get frontSupport => + const AssetGenImage('assets/images/flapper/front-support.png'); +} + class $AssetsImagesFlipperGen { const $AssetsImagesFlipperGen(); @@ -259,6 +273,26 @@ class $AssetsImagesSignpostGen { const AssetGenImage('assets/images/signpost/inactive.png'); } +class $AssetsImagesSkillShotGen { + const $AssetsImagesSkillShotGen(); + + /// File path: assets/images/skill_shot/decal.png + AssetGenImage get decal => + const AssetGenImage('assets/images/skill_shot/decal.png'); + + /// File path: assets/images/skill_shot/dimmed.png + AssetGenImage get dimmed => + const AssetGenImage('assets/images/skill_shot/dimmed.png'); + + /// File path: assets/images/skill_shot/lit.png + AssetGenImage get lit => + const AssetGenImage('assets/images/skill_shot/lit.png'); + + /// File path: assets/images/skill_shot/pin.png + AssetGenImage get pin => + const AssetGenImage('assets/images/skill_shot/pin.png'); +} + class $AssetsImagesSlingshotGen { const $AssetsImagesSlingshotGen(); 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 index 7ddabee8..edce2a78 100644 --- a/packages/pinball_components/lib/src/components/android_bumper/android_bumper.dart +++ b/packages/pinball_components/lib/src/components/android_bumper/android_bumper.dart @@ -78,11 +78,11 @@ class AndroidBumper extends BodyComponent with InitialPosition, ZIndex { AndroidBumper.cow({ Iterable? children, }) : this._( - majorRadius: 3.4, - minorRadius: 2.9, + majorRadius: 3.45, + minorRadius: 3.11, litAssetPath: Assets.images.android.bumper.cow.lit.keyName, dimmedAssetPath: Assets.images.android.bumper.cow.dimmed.keyName, - spritePosition: Vector2(0, -0.68), + spritePosition: Vector2(0, -0.35), bloc: AndroidBumperCubit(), children: [ ...?children, diff --git a/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart b/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart index 4d98b419..d15a5516 100644 --- a/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart +++ b/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart @@ -122,7 +122,7 @@ class _SpaceshipSaucerSpriteAnimationComponent extends SpriteAnimationComponent SpriteAnimationData.sequenced( amount: amountPerRow * amountPerColumn, amountPerRow: amountPerRow, - stepTime: 1 / 24, + stepTime: 1 / 12, textureSize: textureSize, ), ); diff --git a/packages/pinball_components/lib/src/components/backboard/backboard.dart b/packages/pinball_components/lib/src/components/backboard/backboard.dart deleted file mode 100644 index fe5fd37c..00000000 --- a/packages/pinball_components/lib/src/components/backboard/backboard.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:async'; - -import 'package:flame/components.dart'; -import 'package:flutter/material.dart'; -import 'package:pinball_components/pinball_components.dart'; - -export 'backboard_game_over.dart'; -export 'backboard_letter_prompt.dart'; -export 'backboard_waiting.dart'; - -/// {@template backboard} -/// The [Backboard] of the pinball machine. -/// {@endtemplate} -class Backboard extends PositionComponent with HasGameRef { - /// {@macro backboard} - Backboard({ - required Vector2 position, - }) : super( - position: position, - anchor: Anchor.bottomCenter, - ); - - /// {@macro backboard} - /// - /// Returns a [Backboard] initialized in the waiting mode - factory Backboard.waiting({ - required Vector2 position, - }) { - return Backboard(position: position)..waitingMode(); - } - - /// {@macro backboard} - /// - /// Returns a [Backboard] initialized in the game over mode - factory Backboard.gameOver({ - required Vector2 position, - required String characterIconPath, - required int score, - required BackboardOnSubmit onSubmit, - }) { - return Backboard(position: position) - ..gameOverMode( - score: score, - characterIconPath: characterIconPath, - onSubmit: onSubmit, - ); - } - - /// [TextPaint] used on the [Backboard] - static final textPaint = TextPaint( - style: const TextStyle( - fontSize: 6, - color: Colors.white, - fontFamily: PinballFonts.pixeloidSans, - ), - ); - - /// Puts the Backboard in waiting mode, where the scoreboard is shown. - Future waitingMode() async { - children.removeWhere((_) => true); - await add(BackboardWaiting()); - } - - /// Puts the Backboard in game over mode, where the score input is shown. - Future gameOverMode({ - required int score, - required String characterIconPath, - BackboardOnSubmit? onSubmit, - }) async { - children.removeWhere((_) => true); - await add( - BackboardGameOver( - score: score, - characterIconPath: characterIconPath, - onSubmit: onSubmit, - ), - ); - } -} diff --git a/packages/pinball_components/lib/src/components/backboard/backboard_game_over.dart b/packages/pinball_components/lib/src/components/backboard/backboard_game_over.dart deleted file mode 100644 index cfea0bc6..00000000 --- a/packages/pinball_components/lib/src/components/backboard/backboard_game_over.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flame/components.dart'; -import 'package:flutter/services.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// Signature for the callback called when the used has -/// submettied their initials on the [BackboardGameOver] -typedef BackboardOnSubmit = void Function(String); - -/// {@template backboard_game_over} -/// [PositionComponent] that handles the user input on the -/// game over display view. -/// {@endtemplate} -class BackboardGameOver extends PositionComponent with HasGameRef { - /// {@macro backboard_game_over} - BackboardGameOver({ - required int score, - required String characterIconPath, - BackboardOnSubmit? onSubmit, - }) : _onSubmit = onSubmit, - super( - children: [ - _BackboardSpriteComponent(), - _BackboardDisplaySpriteComponent(), - _ScoreTextComponent(score.formatScore()), - _CharacterIconSpriteComponent(characterIconPath), - ], - ); - - final BackboardOnSubmit? _onSubmit; - - @override - Future onLoad() async { - for (var i = 0; i < 3; i++) { - await add( - BackboardLetterPrompt( - position: Vector2( - 24.3 + (4.5 * i), - -45, - ), - hasFocus: i == 0, - ), - ); - } - - await add( - KeyboardInputController( - keyUp: { - LogicalKeyboardKey.arrowLeft: () => _movePrompt(true), - LogicalKeyboardKey.arrowRight: () => _movePrompt(false), - LogicalKeyboardKey.enter: _submit, - }, - ), - ); - } - - /// Returns the current inputed initials - String get initials => children - .whereType() - .map((prompt) => prompt.char) - .join(); - - bool _submit() { - _onSubmit?.call(initials); - return true; - } - - bool _movePrompt(bool left) { - final prompts = children.whereType().toList(); - - final current = prompts.firstWhere((prompt) => prompt.hasFocus) - ..hasFocus = false; - var index = prompts.indexOf(current) + (left ? -1 : 1); - index = min(max(0, index), prompts.length - 1); - - prompts[index].hasFocus = true; - - return false; - } -} - -class _BackboardSpriteComponent extends SpriteComponent with HasGameRef { - _BackboardSpriteComponent() : super(anchor: Anchor.bottomCenter); - - @override - Future onLoad() async { - await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.backboard.backboardGameOver.keyName, - ); - this.sprite = sprite; - size = sprite.originalSize / 10; - } -} - -class _BackboardDisplaySpriteComponent extends SpriteComponent with HasGameRef { - _BackboardDisplaySpriteComponent() - : super( - anchor: Anchor.bottomCenter, - position: Vector2(0, -11.5), - ); - - @override - Future onLoad() async { - await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.backboard.display.keyName, - ); - this.sprite = sprite; - size = sprite.originalSize / 10; - } -} - -class _ScoreTextComponent extends TextComponent { - _ScoreTextComponent(String score) - : super( - text: score, - anchor: Anchor.centerLeft, - position: Vector2(-34, -45), - textRenderer: Backboard.textPaint, - ); -} - -class _CharacterIconSpriteComponent extends SpriteComponent with HasGameRef { - _CharacterIconSpriteComponent(String characterIconPath) - : _characterIconPath = characterIconPath, - super( - anchor: Anchor.center, - position: Vector2(18.4, -45), - ); - - final String _characterIconPath; - - @override - Future onLoad() async { - await super.onLoad(); - final sprite = Sprite(gameRef.images.fromCache(_characterIconPath)); - this.sprite = sprite; - size = sprite.originalSize / 10; - } -} diff --git a/packages/pinball_components/lib/src/components/backboard/backboard_letter_prompt.dart b/packages/pinball_components/lib/src/components/backboard/backboard_letter_prompt.dart deleted file mode 100644 index fe582210..00000000 --- a/packages/pinball_components/lib/src/components/backboard/backboard_letter_prompt.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flame/components.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template backboard_letter_prompt} -/// A [PositionComponent] that renders a letter prompt used -/// on the [BackboardGameOver] -/// {@endtemplate} -class BackboardLetterPrompt extends PositionComponent { - /// {@macro backboard_letter_prompt} - BackboardLetterPrompt({ - required Vector2 position, - bool hasFocus = false, - }) : _hasFocus = hasFocus, - super( - position: position, - ); - - static const _alphabetCode = 65; - static const _alphabetLength = 25; - var _charIndex = 0; - - bool _hasFocus; - - late RectangleComponent _underscore; - late TextComponent _input; - late TimerComponent _underscoreBlinker; - - @override - Future onLoad() async { - _underscore = RectangleComponent( - size: Vector2(3.8, 0.8), - anchor: Anchor.center, - position: Vector2(-0.3, 4), - ); - - await add(_underscore); - - _input = TextComponent( - text: 'A', - textRenderer: Backboard.textPaint, - anchor: Anchor.center, - ); - await add(_input); - - _underscoreBlinker = TimerComponent( - period: 0.6, - repeat: true, - autoStart: _hasFocus, - onTick: () { - _underscore.paint.color = (_underscore.paint.color == Colors.white) - ? Colors.transparent - : Colors.white; - }, - ); - - await add(_underscoreBlinker); - - await add( - KeyboardInputController( - keyUp: { - LogicalKeyboardKey.arrowUp: () => _cycle(true), - LogicalKeyboardKey.arrowDown: () => _cycle(false), - }, - ), - ); - } - - /// Returns the current selected character - String get char => String.fromCharCode(_alphabetCode + _charIndex); - - bool _cycle(bool up) { - if (_hasFocus) { - final newCharCode = - min(max(_charIndex + (up ? 1 : -1), 0), _alphabetLength); - _input.text = String.fromCharCode(_alphabetCode + newCharCode); - _charIndex = newCharCode; - - return false; - } - return true; - } - - /// Returns if this prompt has focus on it - bool get hasFocus => _hasFocus; - - /// Updates this prompt focus - set hasFocus(bool hasFocus) { - if (hasFocus) { - _underscoreBlinker.timer.resume(); - } else { - _underscoreBlinker.timer.pause(); - } - _underscore.paint.color = Colors.white; - _hasFocus = hasFocus; - } -} diff --git a/packages/pinball_components/lib/src/components/backboard/backboard_waiting.dart b/packages/pinball_components/lib/src/components/backboard/backboard_waiting.dart deleted file mode 100644 index f7fa84bf..00000000 --- a/packages/pinball_components/lib/src/components/backboard/backboard_waiting.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:pinball_components/pinball_components.dart'; - -/// [PositionComponent] that shows the leaderboard while the player -/// has not started the game yet. -class BackboardWaiting extends SpriteComponent with HasGameRef { - @override - Future onLoad() async { - final sprite = await gameRef.loadSprite( - Assets.images.backboard.backboardScores.keyName, - ); - - this.sprite = sprite; - size = sprite.originalSize / 10; - anchor = Anchor.bottomCenter; - } -} diff --git a/packages/pinball_components/lib/src/components/ball/ball.dart b/packages/pinball_components/lib/src/components/ball/ball.dart index dea4c0b4..e8cea997 100644 --- a/packages/pinball_components/lib/src/components/ball/ball.dart +++ b/packages/pinball_components/lib/src/components/ball/ball.dart @@ -1,26 +1,25 @@ import 'dart:async'; -import 'dart:math' as math; import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_components/src/components/ball/behaviors/ball_gravitating_behavior.dart'; -import 'package:pinball_components/src/components/ball/behaviors/ball_scaling_behavior.dart'; import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; + +export 'behaviors/behaviors.dart'; /// {@template ball} /// A solid, [BodyType.dynamic] sphere that rolls and bounces around. /// {@endtemplate} -class Ball extends BodyComponent - with Layered, InitialPosition, ZIndex { +class Ball extends BodyComponent with Layered, InitialPosition, ZIndex { /// {@macro ball} Ball({ - required this.baseColor, + String? assetPath, }) : super( renderBody: false, children: [ - _BallSpriteComponent()..tint(baseColor.withOpacity(0.5)), + _BallSpriteComponent(assetPath: assetPath), BallScalingBehavior(), BallGravitatingBehavior(), ], @@ -37,7 +36,7 @@ class Ball extends BodyComponent /// /// This can be used for testing [Ball]'s behaviors in isolation. @visibleForTesting - Ball.test({required this.baseColor}) + Ball.test() : super( children: [_BallSpriteComponent()], ); @@ -45,23 +44,16 @@ class Ball extends BodyComponent /// The size of the [Ball]. static final Vector2 size = Vector2.all(4.13); - /// The base [Color] used to tint this [Ball]. - final Color baseColor; - @override Body createBody() { final shape = CircleShape()..radius = size.x / 2; - final fixtureDef = FixtureDef( - shape, - density: 1, - ); final bodyDef = BodyDef( position: initialPosition, - userData: this, type: BodyType.dynamic, + userData: this, ); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef)..createFixtureFromShape(shape, 1); } /// Immediatly and completly [stop]s the ball. @@ -82,75 +74,25 @@ class Ball extends BodyComponent void resume() { body.gravityScale = Vector2(1, 1); } - - /// Applies a boost and [_TurboChargeSpriteAnimationComponent] on this [Ball]. - Future boost(Vector2 impulse) async { - body.linearVelocity = impulse; - await add(_TurboChargeSpriteAnimationComponent()); - } } class _BallSpriteComponent extends SpriteComponent with HasGameRef { - @override - Future onLoad() async { - await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.ball.ball.keyName, - ); - this.sprite = sprite; - size = sprite.originalSize / 10; - anchor = Anchor.center; - } -} - -class _TurboChargeSpriteAnimationComponent extends SpriteAnimationComponent - with HasGameRef, ZIndex { - _TurboChargeSpriteAnimationComponent() - : super( - anchor: const Anchor(0.53, 0.72), - removeOnFinish: true, - ) { - zIndex = ZIndexes.turboChargeFlame; - } + _BallSpriteComponent({ + this.assetPath, + }) : super( + anchor: Anchor.center, + ); - late final Vector2 _textureSize; + final String? assetPath; @override Future onLoad() async { await super.onLoad(); - - final spriteSheet = await gameRef.images.load( - Assets.images.ball.flameEffect.keyName, - ); - - const amountPerRow = 8; - const amountPerColumn = 4; - _textureSize = Vector2( - spriteSheet.width / amountPerRow, - spriteSheet.height / amountPerColumn, + final sprite = Sprite( + gameRef.images + .fromCache(assetPath ?? theme.Assets.images.dash.ball.keyName), ); - - animation = SpriteAnimation.fromFrameData( - spriteSheet, - SpriteAnimationData.sequenced( - amount: amountPerRow * amountPerColumn, - amountPerRow: amountPerRow, - stepTime: 1 / 24, - textureSize: _textureSize, - loop: false, - ), - ); - } - - @override - void update(double dt) { - super.update(dt); - - if (parent != null) { - final body = (parent! as BodyComponent).body; - final direction = -body.linearVelocity.normalized(); - angle = math.atan2(direction.x, -direction.y); - size = (_textureSize / 45) * body.fixtures.first.shape.radius; - } + this.sprite = sprite; + size = sprite.originalSize / 12.5; } } diff --git a/packages/pinball_components/lib/src/components/ball/behaviors/ball_turbo_charging_behavior.dart b/packages/pinball_components/lib/src/components/ball/behaviors/ball_turbo_charging_behavior.dart new file mode 100644 index 00000000..f1e5a855 --- /dev/null +++ b/packages/pinball_components/lib/src/components/ball/behaviors/ball_turbo_charging_behavior.dart @@ -0,0 +1,81 @@ +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template ball_turbo_charging_behavior} +/// Puts the [Ball] in flames and [_impulse]s it. +/// {@endtemplate} +class BallTurboChargingBehavior extends TimerComponent with ParentIsA { + /// {@macro ball_turbo_charging_behavior} + BallTurboChargingBehavior({ + required Vector2 impulse, + }) : _impulse = impulse, + super(period: 5, removeOnFinish: true); + + final Vector2 _impulse; + + @override + Future onLoad() async { + await super.onLoad(); + + parent.body.linearVelocity = _impulse; + await parent.add(_TurboChargeSpriteAnimationComponent()); + } + + @override + void onRemove() { + parent + .firstChild<_TurboChargeSpriteAnimationComponent>()! + .removeFromParent(); + super.onRemove(); + } +} + +class _TurboChargeSpriteAnimationComponent extends SpriteAnimationComponent + with HasGameRef, ZIndex, ParentIsA { + _TurboChargeSpriteAnimationComponent() + : super( + anchor: const Anchor(0.53, 0.72), + ) { + zIndex = ZIndexes.turboChargeFlame; + } + + late final Vector2 _textureSize; + + @override + void update(double dt) { + super.update(dt); + + final direction = -parent.body.linearVelocity.normalized(); + angle = math.atan2(direction.x, -direction.y); + size = (_textureSize / 45) * parent.body.fixtures.first.shape.radius; + } + + @override + Future onLoad() async { + await super.onLoad(); + + final spriteSheet = await gameRef.images.load( + Assets.images.ball.flameEffect.keyName, + ); + + const amountPerRow = 8; + const amountPerColumn = 4; + _textureSize = Vector2( + spriteSheet.width / amountPerRow, + spriteSheet.height / amountPerColumn, + ); + + animation = SpriteAnimation.fromFrameData( + spriteSheet, + SpriteAnimationData.sequenced( + amount: amountPerRow * amountPerColumn, + amountPerRow: amountPerRow, + stepTime: 1 / 24, + textureSize: _textureSize, + ), + ); + } +} diff --git a/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart index 038b7833..1068a20e 100644 --- a/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart +++ b/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart @@ -1,2 +1,3 @@ export 'ball_gravitating_behavior.dart'; export 'ball_scaling_behavior.dart'; +export 'ball_turbo_charging_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart index 649e804b..06e34199 100644 --- a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart +++ b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart @@ -7,7 +7,7 @@ import 'package:pinball_components/pinball_components.dart'; part 'chrome_dino_state.dart'; class ChromeDinoCubit extends Cubit { - ChromeDinoCubit() : super(const ChromeDinoState.inital()); + ChromeDinoCubit() : super(const ChromeDinoState.initial()); void onOpenMouth() { emit(state.copyWith(isMouthOpen: true)); diff --git a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart index a5d3b183..8ed6fa8c 100644 --- a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart +++ b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart @@ -14,7 +14,7 @@ class ChromeDinoState extends Equatable { this.ball, }); - const ChromeDinoState.inital() + const ChromeDinoState.initial() : this( status: ChromeDinoStatus.idle, isMouthOpen: false, diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 6e79ac56..db2f7d38 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -1,7 +1,6 @@ export 'android_animatronic.dart'; export 'android_bumper/android_bumper.dart'; export 'android_spaceship/android_spaceship.dart'; -export 'backboard/backboard.dart'; export 'ball/ball.dart'; export 'baseboard.dart'; export 'board_background_sprite_component.dart'; @@ -14,6 +13,7 @@ export 'dash_animatronic.dart'; export 'dash_nest_bumper/dash_nest_bumper.dart'; export 'dino_walls.dart'; export 'fire_effect.dart'; +export 'flapper/flapper.dart'; export 'flipper.dart'; export 'google_letter/google_letter.dart'; export 'initial_position.dart'; @@ -21,7 +21,7 @@ export 'joint_anchor.dart'; export 'kicker/kicker.dart'; export 'launch_ramp.dart'; export 'layer.dart'; -export 'layer_sensor.dart'; +export 'layer_sensor/layer_sensor.dart'; export 'multiball/multiball.dart'; export 'multiplier/multiplier.dart'; export 'plunger.dart'; @@ -29,9 +29,10 @@ export 'rocket.dart'; export 'score_component.dart'; export 'shapes/shapes.dart'; export 'signpost/signpost.dart'; +export 'skill_shot/skill_shot.dart'; export 'slingshot.dart'; export 'spaceship_rail.dart'; -export 'spaceship_ramp.dart'; +export 'spaceship_ramp/spaceship_ramp.dart'; export 'sparky_animatronic.dart'; export 'sparky_bumper/sparky_bumper.dart'; export 'sparky_computer.dart'; diff --git a/packages/pinball_components/lib/src/components/dash_nest_bumper/dash_nest_bumper.dart b/packages/pinball_components/lib/src/components/dash_nest_bumper/dash_nest_bumper.dart index 208936c8..4495053d 100644 --- a/packages/pinball_components/lib/src/components/dash_nest_bumper/dash_nest_bumper.dart +++ b/packages/pinball_components/lib/src/components/dash_nest_bumper/dash_nest_bumper.dart @@ -60,10 +60,10 @@ class DashNestBumper extends BodyComponent with InitialPosition { Iterable? children, }) : this._( majorRadius: 3, - minorRadius: 2.5, + minorRadius: 2.2, activeAssetPath: Assets.images.dash.bumper.a.active.keyName, inactiveAssetPath: Assets.images.dash.bumper.a.inactive.keyName, - spritePosition: Vector2(0.35, -1.2), + spritePosition: Vector2(0.3, -1.3), bloc: DashNestBumperCubit(), children: [ ...?children, @@ -75,11 +75,11 @@ class DashNestBumper extends BodyComponent with InitialPosition { DashNestBumper.b({ Iterable? children, }) : this._( - majorRadius: 3, - minorRadius: 2.5, + majorRadius: 3.1, + minorRadius: 2.2, activeAssetPath: Assets.images.dash.bumper.b.active.keyName, inactiveAssetPath: Assets.images.dash.bumper.b.inactive.keyName, - spritePosition: Vector2(0.35, -1.2), + spritePosition: Vector2(0.4, -1.2), bloc: DashNestBumperCubit(), children: [ ...?children, diff --git a/packages/pinball_components/lib/src/components/flapper/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/flapper/behaviors/behaviors.dart new file mode 100644 index 00000000..573578e5 --- /dev/null +++ b/packages/pinball_components/lib/src/components/flapper/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'flapper_spinning_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/flapper/behaviors/flapper_spinning_behavior.dart b/packages/pinball_components/lib/src/components/flapper/behaviors/flapper_spinning_behavior.dart new file mode 100644 index 00000000..9a4e2a99 --- /dev/null +++ b/packages/pinball_components/lib/src/components/flapper/behaviors/flapper_spinning_behavior.dart @@ -0,0 +1,15 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class FlapperSpinningBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + parent.parent?.firstChild()?.playing = true; + } +} diff --git a/packages/pinball_components/lib/src/components/flapper/flapper.dart b/packages/pinball_components/lib/src/components/flapper/flapper.dart new file mode 100644 index 00000000..f336273e --- /dev/null +++ b/packages/pinball_components/lib/src/components/flapper/flapper.dart @@ -0,0 +1,215 @@ +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/flapper/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template flapper} +/// Flap to let a [Ball] out of the [LaunchRamp] and to prevent [Ball]s from +/// going back in. +/// {@endtemplate} +class Flapper extends Component { + /// {@macro flapper} + Flapper() + : super( + children: [ + FlapperEntrance( + children: [ + FlapperSpinningBehavior(), + ], + )..initialPosition = Vector2(4, -69.3), + _FlapperStructure(), + _FlapperExit()..initialPosition = Vector2(-0.6, -33.8), + _BackSupportSpriteComponent(), + _FrontSupportSpriteComponent(), + FlapSpriteAnimationComponent(), + ], + ); + + /// Creates a [Flapper] without any children. + /// + /// This can be used for testing [Flapper]'s behaviors in isolation. + @visibleForTesting + Flapper.test(); +} + +/// {@template flapper_entrance} +/// Sensor used in [FlapperSpinningBehavior] to animate +/// [FlapSpriteAnimationComponent]. +/// {@endtemplate} +class FlapperEntrance extends BodyComponent with InitialPosition, Layered { + /// {@macro flapper_entrance} + FlapperEntrance({ + Iterable? children, + }) : super( + children: children, + renderBody: false, + ) { + layer = Layer.launcher; + } + + @override + Body createBody() { + final shape = EdgeShape() + ..set( + Vector2.zero(), + Vector2(0, 3.2), + ); + final fixtureDef = FixtureDef( + shape, + isSensor: true, + ); + final bodyDef = BodyDef(position: initialPosition); + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} + +class _FlapperStructure extends BodyComponent with Layered { + _FlapperStructure() : super(renderBody: false) { + layer = Layer.board; + } + + List _createFixtureDefs() { + final leftEdgeShape = EdgeShape() + ..set( + Vector2(1.9, -69.3), + Vector2(1.9, -66), + ); + + final bottomEdgeShape = EdgeShape() + ..set( + leftEdgeShape.vertex2, + Vector2(3.9, -66), + ); + + return [ + FixtureDef(leftEdgeShape), + FixtureDef(bottomEdgeShape), + ]; + } + + @override + Body createBody() { + final body = world.createBody(BodyDef()); + _createFixtureDefs().forEach(body.createFixture); + return body; + } +} + +class _FlapperExit extends LayerSensor { + _FlapperExit() + : super( + insideLayer: Layer.launcher, + outsideLayer: Layer.board, + orientation: LayerEntranceOrientation.down, + insideZIndex: ZIndexes.ballOnLaunchRamp, + outsideZIndex: ZIndexes.ballOnBoard, + ) { + layer = Layer.launcher; + } + + @override + Shape get shape => PolygonShape() + ..setAsBox( + 1.7, + 0.1, + initialPosition, + 1.5708, + ); +} + +/// {@template flap_sprite_animation_component} +/// Flap suspended between supports that animates to let the [Ball] exit the +/// [LaunchRamp]. +/// {@endtemplate} +@visibleForTesting +class FlapSpriteAnimationComponent extends SpriteAnimationComponent + with HasGameRef, ZIndex { + /// {@macro flap_sprite_animation_component} + FlapSpriteAnimationComponent() + : super( + anchor: Anchor.center, + position: Vector2(2.8, -70.7), + playing: false, + ) { + zIndex = ZIndexes.flapper; + } + + @override + Future onLoad() async { + await super.onLoad(); + + final spriteSheet = gameRef.images.fromCache( + Assets.images.flapper.flap.keyName, + ); + + const amountPerRow = 14; + const amountPerColumn = 1; + final textureSize = Vector2( + spriteSheet.width / amountPerRow, + spriteSheet.height / amountPerColumn, + ); + size = textureSize / 10; + + animation = SpriteAnimation.fromFrameData( + spriteSheet, + SpriteAnimationData.sequenced( + amount: amountPerRow * amountPerColumn, + amountPerRow: amountPerRow, + stepTime: 1 / 24, + textureSize: textureSize, + loop: false, + ), + )..onComplete = () { + animation?.reset(); + playing = false; + }; + } +} + +class _BackSupportSpriteComponent extends SpriteComponent + with HasGameRef, ZIndex { + _BackSupportSpriteComponent() + : super( + anchor: Anchor.center, + position: Vector2(2.95, -70.6), + ) { + zIndex = ZIndexes.flapperBack; + } + + @override + Future onLoad() async { + await super.onLoad(); + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.flapper.backSupport.keyName, + ), + ); + this.sprite = sprite; + size = sprite.originalSize / 10; + } +} + +class _FrontSupportSpriteComponent extends SpriteComponent + with HasGameRef, ZIndex { + _FrontSupportSpriteComponent() + : super( + anchor: Anchor.center, + position: Vector2(2.9, -67.6), + ) { + zIndex = ZIndexes.flapperFront; + } + + @override + Future onLoad() async { + await super.onLoad(); + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.flapper.frontSupport.keyName, + ), + ); + this.sprite = sprite; + size = sprite.originalSize / 10; + } +} diff --git a/packages/pinball_components/lib/src/components/initial_position.dart b/packages/pinball_components/lib/src/components/initial_position.dart index d79f8d64..4265a3a7 100644 --- a/packages/pinball_components/lib/src/components/initial_position.dart +++ b/packages/pinball_components/lib/src/components/initial_position.dart @@ -5,7 +5,7 @@ import 'package:flame_forge2d/flame_forge2d.dart'; /// /// Note: If the [initialPosition] is set after the [BodyComponent] has been /// loaded it will have no effect; defaulting to [Vector2.zero]. -mixin InitialPosition on BodyComponent { +mixin InitialPosition on BodyComponent { final Vector2 _initialPosition = Vector2.zero(); set initialPosition(Vector2 value) { diff --git a/packages/pinball_components/lib/src/components/launch_ramp.dart b/packages/pinball_components/lib/src/components/launch_ramp.dart index 4713c3a2..7dcc274e 100644 --- a/packages/pinball_components/lib/src/components/launch_ramp.dart +++ b/packages/pinball_components/lib/src/components/launch_ramp.dart @@ -1,7 +1,5 @@ // ignore_for_file: avoid_renaming_method_parameters -import 'dart:math' as math; - import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -17,8 +15,6 @@ class LaunchRamp extends Component { children: [ _LaunchRampBase(), _LaunchRampForegroundRailing(), - _LaunchRampExit()..initialPosition = Vector2(0.6, -34), - _LaunchRampCloseWall()..initialPosition = Vector2(4, -69.5), ], ); } @@ -109,8 +105,10 @@ class _LaunchRampBaseSpriteComponent extends SpriteComponent with HasGameRef { Future onLoad() async { await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.launchRamp.ramp.keyName, + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.launchRamp.ramp.keyName, + ), ); this.sprite = sprite; size = sprite.originalSize / 10; @@ -125,8 +123,10 @@ class _LaunchRampBackgroundRailingSpriteComponent extends SpriteComponent Future onLoad() async { await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.launchRamp.backgroundRailing.keyName, + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.launchRamp.backgroundRailing.keyName, + ), ); this.sprite = sprite; size = sprite.originalSize / 10; @@ -190,8 +190,10 @@ class _LaunchRampForegroundRailingSpriteComponent extends SpriteComponent Future onLoad() async { await super.onLoad(); - final sprite = await gameRef.loadSprite( - Assets.images.launchRamp.foregroundRailing.keyName, + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.launchRamp.foregroundRailing.keyName, + ), ); this.sprite = sprite; size = sprite.originalSize / 10; @@ -199,51 +201,3 @@ class _LaunchRampForegroundRailingSpriteComponent extends SpriteComponent position = Vector2(22.8, 0.5); } } - -class _LaunchRampCloseWall extends BodyComponent with InitialPosition, Layered { - _LaunchRampCloseWall() : super(renderBody: false) { - layer = Layer.board; - } - - @override - Body createBody() { - final shape = EdgeShape()..set(Vector2.zero(), Vector2(0, 3)); - - final fixtureDef = FixtureDef(shape); - - final bodyDef = BodyDef() - ..userData = this - ..position = initialPosition; - - return world.createBody(bodyDef)..createFixture(fixtureDef); - } -} - -/// {@template launch_ramp_exit} -/// [LayerSensor] with [Layer.launcher] to filter [Ball]s exiting the -/// [LaunchRamp]. -/// {@endtemplate} -class _LaunchRampExit extends LayerSensor { - /// {@macro launch_ramp_exit} - _LaunchRampExit() - : super( - insideLayer: Layer.launcher, - outsideLayer: Layer.board, - orientation: LayerEntranceOrientation.down, - insideZIndex: ZIndexes.ballOnLaunchRamp, - outsideZIndex: ZIndexes.ballOnBoard, - ) { - layer = Layer.launcher; - } - - static final Vector2 _size = Vector2(1.6, 0.1); - - @override - Shape get shape => PolygonShape() - ..setAsBox( - _size.x, - _size.y, - initialPosition, - math.pi / 2, - ); -} diff --git a/packages/pinball_components/lib/src/components/layer.dart b/packages/pinball_components/lib/src/components/layer.dart index a39ad837..8418fac1 100644 --- a/packages/pinball_components/lib/src/components/layer.dart +++ b/packages/pinball_components/lib/src/components/layer.dart @@ -9,7 +9,7 @@ import 'package:flutter/material.dart'; /// ignoring others. This compatibility depends on bit masking operation /// between layers. For more information read: https://en.wikipedia.org/wiki/Mask_(computing). /// {@endtemplate} -mixin Layered on BodyComponent { +mixin Layered on BodyComponent { Layer _layer = Layer.all; /// {@macro layered} diff --git a/packages/pinball_components/lib/src/components/layer_sensor.dart b/packages/pinball_components/lib/src/components/layer_sensor.dart deleted file mode 100644 index 6b5f3832..00000000 --- a/packages/pinball_components/lib/src/components/layer_sensor.dart +++ /dev/null @@ -1,90 +0,0 @@ -// ignore_for_file: avoid_renaming_method_parameters - -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball_components/pinball_components.dart'; - -/// {@template layer_entrance_orientation} -/// Determines if a layer entrance is oriented [up] or [down] on the board. -/// {@endtemplate} -enum LayerEntranceOrientation { - /// Facing up on the Board. - up, - - /// Facing down on the Board. - down, -} - -/// {@template layer_sensor} -/// [BodyComponent] located at the entrance and exit of a [Layer]. -/// -/// By default the base [layer] is set to [Layer.board] and the -/// [_outsideZIndex] is set to [ZIndexes.ballOnBoard]. -/// {@endtemplate} -abstract class LayerSensor extends BodyComponent - with InitialPosition, Layered, ContactCallbacks { - /// {@macro layer_sensor} - LayerSensor({ - required Layer insideLayer, - Layer? outsideLayer, - required int insideZIndex, - int? outsideZIndex, - required this.orientation, - }) : _insideLayer = insideLayer, - _outsideLayer = outsideLayer ?? Layer.board, - _insideZIndex = insideZIndex, - _outsideZIndex = outsideZIndex ?? ZIndexes.ballOnBoard, - super(renderBody: false) { - layer = Layer.opening; - } - - final Layer _insideLayer; - final Layer _outsideLayer; - final int _insideZIndex; - final int _outsideZIndex; - - /// The [Shape] of the [LayerSensor]. - Shape get shape; - - /// {@macro layer_entrance_orientation} - // TODO(ruimiguel): Try to remove the need of [LayerEntranceOrientation] for - // collision calculations. - final LayerEntranceOrientation orientation; - - @override - Body createBody() { - final fixtureDef = FixtureDef( - shape, - isSensor: true, - ); - final bodyDef = BodyDef( - position: initialPosition, - userData: this, - ); - - return world.createBody(bodyDef)..createFixture(fixtureDef); - } - - @override - void beginContact(Object other, Contact contact) { - super.beginContact(other, contact); - if (other is! Ball) return; - - if (other.layer != _insideLayer) { - final isBallEnteringOpening = - (orientation == LayerEntranceOrientation.down && - other.body.linearVelocity.y < 0) || - (orientation == LayerEntranceOrientation.up && - other.body.linearVelocity.y > 0); - - if (isBallEnteringOpening) { - other - ..layer = _insideLayer - ..zIndex = _insideZIndex; - } - } else { - other - ..layer = _outsideLayer - ..zIndex = _outsideZIndex; - } - } -} diff --git a/packages/pinball_components/lib/src/components/layer_sensor/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/layer_sensor/behaviors/behaviors.dart new file mode 100644 index 00000000..060e313d --- /dev/null +++ b/packages/pinball_components/lib/src/components/layer_sensor/behaviors/behaviors.dart @@ -0,0 +1,2 @@ +export 'behaviors.dart'; +export 'layer_filtering_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/layer_sensor/behaviors/layer_filtering_behavior.dart b/packages/pinball_components/lib/src/components/layer_sensor/behaviors/layer_filtering_behavior.dart new file mode 100644 index 00000000..06dca4b6 --- /dev/null +++ b/packages/pinball_components/lib/src/components/layer_sensor/behaviors/layer_filtering_behavior.dart @@ -0,0 +1,31 @@ +// 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 LayerFilteringBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + + if (other.layer != parent.insideLayer) { + final isBallEnteringOpening = + (parent.orientation == LayerEntranceOrientation.down && + other.body.linearVelocity.y < 0) || + (parent.orientation == LayerEntranceOrientation.up && + other.body.linearVelocity.y > 0); + + if (isBallEnteringOpening) { + other + ..layer = parent.insideLayer + ..zIndex = parent.insideZIndex; + } + } else { + other + ..layer = parent.outsideLayer + ..zIndex = parent.outsideZIndex; + } + } +} diff --git a/packages/pinball_components/lib/src/components/layer_sensor/layer_sensor.dart b/packages/pinball_components/lib/src/components/layer_sensor/layer_sensor.dart new file mode 100644 index 00000000..4b1d6ae3 --- /dev/null +++ b/packages/pinball_components/lib/src/components/layer_sensor/layer_sensor.dart @@ -0,0 +1,66 @@ +// ignore_for_file: avoid_renaming_method_parameters, public_member_api_docs + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/layer_sensor/behaviors/layer_filtering_behavior.dart'; + +/// {@template layer_entrance_orientation} +/// Determines if a layer entrance is oriented [up] or [down] on the board. +/// {@endtemplate} +enum LayerEntranceOrientation { + /// Facing up on the Board. + up, + + /// Facing down on the Board. + down, +} + +/// {@template layer_sensor} +/// [BodyComponent] located at the entrance and exit of a [Layer]. +/// +/// By default the base [layer] is set to [Layer.board] and the +/// [outsideZIndex] is set to [ZIndexes.ballOnBoard]. +/// {@endtemplate} +abstract class LayerSensor extends BodyComponent with InitialPosition, Layered { + /// {@macro layer_sensor} + LayerSensor({ + required this.insideLayer, + Layer? outsideLayer, + required this.insideZIndex, + int? outsideZIndex, + required this.orientation, + }) : outsideLayer = outsideLayer ?? Layer.board, + outsideZIndex = outsideZIndex ?? ZIndexes.ballOnBoard, + super( + renderBody: false, + children: [LayerFilteringBehavior()], + ) { + layer = Layer.opening; + } + + final Layer insideLayer; + + final Layer outsideLayer; + + final int insideZIndex; + + final int outsideZIndex; + + /// The [Shape] of the [LayerSensor]. + Shape get shape; + + /// {@macro layer_entrance_orientation} + // TODO(ruimiguel): Try to remove the need of [LayerEntranceOrientation] for + // collision calculations. + final LayerEntranceOrientation orientation; + + @override + Body createBody() { + final fixtureDef = FixtureDef( + shape, + isSensor: true, + ); + final bodyDef = BodyDef(position: initialPosition); + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} diff --git a/packages/pinball_components/lib/src/components/plunger.dart b/packages/pinball_components/lib/src/components/plunger.dart index 79b370a0..040c3287 100644 --- a/packages/pinball_components/lib/src/components/plunger.dart +++ b/packages/pinball_components/lib/src/components/plunger.dart @@ -97,7 +97,7 @@ class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex { void update(double dt) { // Ensure that we only pull or release when the time is greater than zero. if (_pullingDownTime > 0) { - _pullingDownTime -= dt; + _pullingDownTime -= PinballForge2DGame.clampDt(dt); if (_pullingDownTime <= 0) { release(); } else { diff --git a/packages/pinball_components/lib/src/components/score_component.dart b/packages/pinball_components/lib/src/components/score_component.dart index 12d198cb..5f95878a 100644 --- a/packages/pinball_components/lib/src/components/score_component.dart +++ b/packages/pinball_components/lib/src/components/score_component.dart @@ -23,16 +23,20 @@ class ScoreComponent extends SpriteComponent with HasGameRef, ZIndex { ScoreComponent({ required this.points, required Vector2 position, - }) : super( + required EffectController effectController, + }) : _effectController = effectController, + super( position: position, anchor: Anchor.center, ) { zIndex = ZIndexes.score; } + late Points points; + late final Effect _effect; - late Points points; + final EffectController _effectController; @override Future onLoad() async { @@ -46,7 +50,7 @@ class ScoreComponent extends SpriteComponent with HasGameRef, ZIndex { await add( _effect = MoveEffect.by( Vector2(0, -5), - EffectController(duration: 1), + _effectController, ), ); } diff --git a/packages/pinball_components/lib/src/components/skill_shot/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/skill_shot/behaviors/behaviors.dart new file mode 100644 index 00000000..03aa31bd --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/behaviors/behaviors.dart @@ -0,0 +1,2 @@ +export 'skill_shot_ball_contact_behavior.dart'; +export 'skill_shot_blinking_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior.dart b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior.dart new file mode 100644 index 00000000..62e4185f --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior.dart @@ -0,0 +1,16 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class SkillShotBallContactBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + parent.bloc.onBallContacted(); + parent.firstChild()?.playing = true; + } +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_blinking_behavior.dart b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_blinking_behavior.dart new file mode 100644 index 00000000..ea62fc25 --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_blinking_behavior.dart @@ -0,0 +1,44 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template skill_shot_blinking_behavior} +/// Makes a [SkillShot] blink between [SkillShotSpriteState.lit] and +/// [SkillShotSpriteState.dimmed] for a set amount of blinks. +/// {@endtemplate} +class SkillShotBlinkingBehavior extends TimerComponent + with ParentIsA { + /// {@macro skill_shot_blinking_behavior} + SkillShotBlinkingBehavior() : super(period: 0.15); + + final _maxBlinks = 4; + int _blinks = 0; + + void _onNewState(SkillShotState state) { + if (state.isBlinking) { + timer + ..reset() + ..start(); + } + } + + @override + Future onLoad() async { + await super.onLoad(); + timer.stop(); + parent.bloc.stream.listen(_onNewState); + } + + @override + void onTick() { + super.onTick(); + if (_blinks != _maxBlinks * 2) { + parent.bloc.switched(); + _blinks++; + } else { + _blinks = 0; + timer.stop(); + parent.bloc.onBlinkingFinished(); + } + } +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_cubit.dart b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_cubit.dart new file mode 100644 index 00000000..b9491385 --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_cubit.dart @@ -0,0 +1,39 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'skill_shot_state.dart'; + +class SkillShotCubit extends Cubit { + SkillShotCubit() : super(const SkillShotState.initial()); + + void onBallContacted() { + emit( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ); + } + + void switched() { + switch (state.spriteState) { + case SkillShotSpriteState.lit: + emit(state.copyWith(spriteState: SkillShotSpriteState.dimmed)); + break; + case SkillShotSpriteState.dimmed: + emit(state.copyWith(spriteState: SkillShotSpriteState.lit)); + break; + } + } + + void onBlinkingFinished() { + emit( + const SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ), + ); + } +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_state.dart b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_state.dart new file mode 100644 index 00000000..1e040db6 --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_state.dart @@ -0,0 +1,37 @@ +// ignore_for_file: public_member_api_docs + +part of 'skill_shot_cubit.dart'; + +enum SkillShotSpriteState { + lit, + dimmed, +} + +class SkillShotState extends Equatable { + const SkillShotState({ + required this.spriteState, + required this.isBlinking, + }); + + const SkillShotState.initial() + : this( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ); + + final SkillShotSpriteState spriteState; + + final bool isBlinking; + + SkillShotState copyWith({ + SkillShotSpriteState? spriteState, + bool? isBlinking, + }) => + SkillShotState( + spriteState: spriteState ?? this.spriteState, + isBlinking: isBlinking ?? this.isBlinking, + ); + + @override + List get props => [spriteState, isBlinking]; +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/skill_shot.dart b/packages/pinball_components/lib/src/components/skill_shot/skill_shot.dart new file mode 100644 index 00000000..3bf10a7e --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/skill_shot.dart @@ -0,0 +1,169 @@ +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/skill_shot/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'cubit/skill_shot_cubit.dart'; + +/// {@template skill_shot} +/// Rollover awarding extra points. +/// {@endtemplate} +class SkillShot extends BodyComponent with ZIndex { + /// {@macro skill_shot} + SkillShot({Iterable? children}) + : this._( + children: children, + bloc: SkillShotCubit(), + ); + + SkillShot._({ + Iterable? children, + required this.bloc, + }) : super( + renderBody: false, + children: [ + SkillShotBallContactBehavior(), + SkillShotBlinkingBehavior(), + _RolloverDecalSpriteComponent(), + PinSpriteAnimationComponent(), + _TextDecalSpriteGroupComponent(state: bloc.state.spriteState), + ...?children, + ], + ) { + zIndex = ZIndexes.decal; + } + + /// Creates a [SkillShot] without any children. + /// + /// This can be used for testing [SkillShot]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + SkillShot.test({ + required this.bloc, + }); + + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final SkillShotCubit bloc; + + @override + void onRemove() { + bloc.close(); + super.onRemove(); + } + + @override + Body createBody() { + final shape = PolygonShape() + ..setAsBox( + 0.1, + 3.7, + Vector2(-31.9, 9.1), + 0.11, + ); + final fixtureDef = FixtureDef(shape, isSensor: true); + return world.createBody(BodyDef())..createFixture(fixtureDef); + } +} + +class _RolloverDecalSpriteComponent extends SpriteComponent with HasGameRef { + _RolloverDecalSpriteComponent() + : super( + anchor: Anchor.center, + position: Vector2(-31.9, 9.1), + angle: 0.11, + ); + + @override + Future onLoad() async { + await super.onLoad(); + + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.skillShot.decal.keyName, + ), + ); + this.sprite = sprite; + size = sprite.originalSize / 20; + } +} + +/// {@template pin_sprite_animation_component} +/// Animation for pin in [SkillShot] rollover. +/// {@endtemplate} +@visibleForTesting +class PinSpriteAnimationComponent extends SpriteAnimationComponent + with HasGameRef { + /// {@macro pin_sprite_animation_component} + PinSpriteAnimationComponent() + : super( + anchor: Anchor.center, + position: Vector2(-31.9, 9.1), + angle: 0, + playing: false, + ); + + @override + Future onLoad() async { + await super.onLoad(); + + final spriteSheet = gameRef.images.fromCache( + Assets.images.skillShot.pin.keyName, + ); + + const amountPerRow = 3; + const amountPerColumn = 1; + final textureSize = Vector2( + spriteSheet.width / amountPerRow, + spriteSheet.height / amountPerColumn, + ); + size = textureSize / 10; + + animation = SpriteAnimation.fromFrameData( + spriteSheet, + SpriteAnimationData.sequenced( + amount: amountPerRow * amountPerColumn, + amountPerRow: amountPerRow, + stepTime: 1 / 24, + textureSize: textureSize, + loop: false, + ), + )..onComplete = () { + animation?.reset(); + playing = false; + }; + } +} + +class _TextDecalSpriteGroupComponent + extends SpriteGroupComponent + with HasGameRef, ParentIsA { + _TextDecalSpriteGroupComponent({ + required SkillShotSpriteState state, + }) : super( + anchor: Anchor.center, + position: Vector2(-35.55, 3.59), + current: state, + ); + + @override + Future onLoad() async { + await super.onLoad(); + parent.bloc.stream.listen((state) => current = state.spriteState); + + final sprites = { + SkillShotSpriteState.lit: Sprite( + gameRef.images.fromCache(Assets.images.skillShot.lit.keyName), + ), + SkillShotSpriteState.dimmed: Sprite( + gameRef.images.fromCache(Assets.images.skillShot.dimmed.keyName), + ), + }; + this.sprites = sprites; + size = sprites[current]!.originalSize / 10; + } +} diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/behavior.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/behavior.dart new file mode 100644 index 00000000..1f9b6284 --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/behavior.dart @@ -0,0 +1 @@ +export 'ramp_ball_ascending_contact_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior.dart new file mode 100644 index 00000000..2d0aad7c --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior.dart @@ -0,0 +1,24 @@ +// 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'; + +/// {@template ramp_ball_ascending_contact_behavior} +/// Detects an ascending [Ball] that enters into the [SpaceshipRamp]. +/// +/// The [Ball] can hit with sensor to recognize if a [Ball] goes into or out of +/// the [SpaceshipRamp]. +/// {@endtemplate} +class RampBallAscendingContactBehavior + extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + + if (other.body.linearVelocity.y < 0) { + parent.parent.bloc.onAscendingBallEntered(); + } + } +} diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit.dart new file mode 100644 index 00000000..d27a7a2c --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit.dart @@ -0,0 +1,16 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'spaceship_ramp_state.dart'; + +class SpaceshipRampCubit extends Cubit { + SpaceshipRampCubit() : super(const SpaceshipRampState.initial()); + + void onAscendingBallEntered() { + emit( + state.copyWith(hits: state.hits + 1), + ); + } +} diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_state.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_state.dart new file mode 100644 index 00000000..7fae894f --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_state.dart @@ -0,0 +1,24 @@ +// ignore_for_file: public_member_api_docs + +part of 'spaceship_ramp_cubit.dart'; + +class SpaceshipRampState extends Equatable { + const SpaceshipRampState({ + required this.hits, + }) : assert(hits >= 0, "Hits can't be negative"); + + const SpaceshipRampState.initial() : this(hits: 0); + + final int hits; + + SpaceshipRampState copyWith({ + int? hits, + }) { + return SpaceshipRampState( + hits: hits ?? this.hits, + ); + } + + @override + List get props => [hits]; +} diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart similarity index 77% rename from packages/pinball_components/lib/src/components/spaceship_ramp.dart rename to packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart index c1be0943..0b407517 100644 --- a/packages/pinball_components/lib/src/components/spaceship_ramp.dart +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart @@ -5,16 +5,35 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; import 'package:pinball_components/gen/assets.gen.dart'; import 'package:pinball_components/pinball_components.dart' hide Assets; +import 'package:pinball_components/src/components/spaceship_ramp/behavior/behavior.dart'; import 'package:pinball_flame/pinball_flame.dart'; +export 'cubit/spaceship_ramp_cubit.dart'; + /// {@template spaceship_ramp} /// Ramp leading into the [AndroidSpaceship]. /// {@endtemplate} class SpaceshipRamp extends Component { /// {@macro spaceship_ramp} - SpaceshipRamp() - : super( + SpaceshipRamp({ + Iterable? children, + }) : this._( + children: children, + bloc: SpaceshipRampCubit(), + ); + + SpaceshipRamp._({ + Iterable? children, + required this.bloc, + }) : super( children: [ + // TODO(ruimiguel): refactor RampScoringSensor and + // _SpaceshipRampOpening to be in only one sensor if possible. + RampScoringSensor( + children: [ + RampBallAscendingContactBehavior(), + ], + )..initialPosition = Vector2(1.7, -20.4), _SpaceshipRampOpening( outsidePriority: ZIndexes.ballOnBoard, rotation: math.pi, @@ -34,60 +53,30 @@ class SpaceshipRamp extends Component { _SpaceshipRampForegroundRailing(), _SpaceshipRampBase()..initialPosition = Vector2(1.7, -20), _SpaceshipRampBackgroundRailingSpriteComponent(), - _SpaceshipRampArrowSpriteComponent(), + SpaceshipRampArrowSpriteComponent( + current: bloc.state.hits, + ), + ...?children, ], ); - /// Forwards the sprite to the next [SpaceshipRampArrowSpriteState]. + /// Creates a [SpaceshipRamp] without any children. /// - /// If the current state is the last one it cycles back to the initial state. - void progress() => - firstChild<_SpaceshipRampArrowSpriteComponent>()?.progress(); -} - -/// Indicates the state of the arrow on the [SpaceshipRamp]. -@visibleForTesting -enum SpaceshipRampArrowSpriteState { - /// Arrow with no dashes lit up. - inactive, - - /// Arrow with 1 light lit up. - active1, + /// This can be used for testing [SpaceshipRamp]'s behaviors in isolation. + @visibleForTesting + SpaceshipRamp.test({ + required this.bloc, + }) : super(); - /// Arrow with 2 lights lit up. - active2, - - /// Arrow with 3 lights lit up. - active3, - - /// Arrow with 4 lights lit up. - active4, - - /// Arrow with all 5 lights lit up. - active5, -} - -extension on SpaceshipRampArrowSpriteState { - String get path { - switch (this) { - case SpaceshipRampArrowSpriteState.inactive: - return Assets.images.android.ramp.arrow.inactive.keyName; - case SpaceshipRampArrowSpriteState.active1: - return Assets.images.android.ramp.arrow.active1.keyName; - case SpaceshipRampArrowSpriteState.active2: - return Assets.images.android.ramp.arrow.active2.keyName; - case SpaceshipRampArrowSpriteState.active3: - return Assets.images.android.ramp.arrow.active3.keyName; - case SpaceshipRampArrowSpriteState.active4: - return Assets.images.android.ramp.arrow.active4.keyName; - case SpaceshipRampArrowSpriteState.active5: - return Assets.images.android.ramp.arrow.active5.keyName; - } - } + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final SpaceshipRampCubit bloc; - SpaceshipRampArrowSpriteState get next { - return SpaceshipRampArrowSpriteState - .values[(index + 1) % SpaceshipRampArrowSpriteState.values.length]; + @override + void onRemove() { + bloc.close(); + super.onRemove(); } } @@ -194,37 +183,81 @@ class _SpaceshipRampBackgroundRampSpriteComponent extends SpriteComponent /// /// Lights progressively whenever a [Ball] gets into [SpaceshipRamp]. /// {@endtemplate} -class _SpaceshipRampArrowSpriteComponent - extends SpriteGroupComponent - with HasGameRef, ZIndex { +@visibleForTesting +class SpaceshipRampArrowSpriteComponent extends SpriteGroupComponent + with HasGameRef, ParentIsA, ZIndex { /// {@macro spaceship_ramp_arrow_sprite_component} - _SpaceshipRampArrowSpriteComponent() - : super( + SpaceshipRampArrowSpriteComponent({ + required int current, + }) : super( anchor: Anchor.center, position: Vector2(-3.9, -56.5), + current: current, ) { zIndex = ZIndexes.spaceshipRampArrow; } - /// Changes arrow image to the next [Sprite]. - void progress() => current = current?.next; - @override Future onLoad() async { await super.onLoad(); - final sprites = {}; + parent.bloc.stream.listen((state) { + current = state.hits % SpaceshipRampArrowSpriteState.values.length; + }); + + final sprites = {}; this.sprites = sprites; for (final spriteState in SpaceshipRampArrowSpriteState.values) { - sprites[spriteState] = Sprite( + sprites[spriteState.index] = Sprite( gameRef.images.fromCache(spriteState.path), ); } - current = SpaceshipRampArrowSpriteState.inactive; + current = 0; size = sprites[current]!.originalSize / 10; } } +/// Indicates the state of the arrow on the [SpaceshipRamp]. +@visibleForTesting +enum SpaceshipRampArrowSpriteState { + /// Arrow with no dashes lit up. + inactive, + + /// Arrow with 1 light lit up. + active1, + + /// Arrow with 2 lights lit up. + active2, + + /// Arrow with 3 lights lit up. + active3, + + /// Arrow with 4 lights lit up. + active4, + + /// Arrow with all 5 lights lit up. + active5, +} + +extension on SpaceshipRampArrowSpriteState { + String get path { + switch (this) { + case SpaceshipRampArrowSpriteState.inactive: + return Assets.images.android.ramp.arrow.inactive.keyName; + case SpaceshipRampArrowSpriteState.active1: + return Assets.images.android.ramp.arrow.active1.keyName; + case SpaceshipRampArrowSpriteState.active2: + return Assets.images.android.ramp.arrow.active2.keyName; + case SpaceshipRampArrowSpriteState.active3: + return Assets.images.android.ramp.arrow.active3.keyName; + case SpaceshipRampArrowSpriteState.active4: + return Assets.images.android.ramp.arrow.active4.keyName; + case SpaceshipRampArrowSpriteState.active5: + return Assets.images.android.ramp.arrow.active5.keyName; + } + } +} + class _SpaceshipRampBoardOpeningSpriteComponent extends SpriteComponent with HasGameRef, ZIndex { _SpaceshipRampBoardOpeningSpriteComponent() : super(anchor: Anchor.center) { @@ -373,3 +406,47 @@ class _SpaceshipRampOpening extends LayerSensor { ); } } + +/// {@template ramp_scoring_sensor} +/// Small sensor body used to detect when a ball has entered the +/// [SpaceshipRamp]. +/// {@endtemplate} +class RampScoringSensor extends BodyComponent + with ParentIsA, InitialPosition, Layered { + /// {@macro ramp_scoring_sensor} + RampScoringSensor({ + Iterable? children, + }) : super( + children: children, + renderBody: false, + ) { + layer = Layer.spaceshipEntranceRamp; + } + + /// Creates a [RampScoringSensor] without any children. + /// + @visibleForTesting + RampScoringSensor.test(); + + @override + Body createBody() { + final shape = PolygonShape() + ..setAsBox( + 2.6, + .5, + initialPosition, + -5 * math.pi / 180, + ); + + final fixtureDef = FixtureDef( + shape, + isSensor: true, + ); + final bodyDef = BodyDef( + position: initialPosition, + userData: this, + ); + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} diff --git a/packages/pinball_components/lib/src/components/sparky_computer.dart b/packages/pinball_components/lib/src/components/sparky_computer.dart index 512c9d48..3495c83a 100644 --- a/packages/pinball_components/lib/src/components/sparky_computer.dart +++ b/packages/pinball_components/lib/src/components/sparky_computer.dart @@ -32,18 +32,18 @@ class _ComputerBase extends BodyComponent with InitialPosition, ZIndex { List _createFixtureDefs() { final leftEdge = EdgeShape() ..set( - Vector2(-14.9, -46), - Vector2(-15.3, -49.6), + Vector2(-15.1, -45.9), + Vector2(-15.5, -49.5), ); final topEdge = EdgeShape() ..set( - Vector2(-15.3, -49.6), - Vector2(-10.7, -50.6), + leftEdge.vertex2, + Vector2(-10.9, -50.5), ); final rightEdge = EdgeShape() ..set( - Vector2(-10.7, -50.6), - Vector2(-9, -47.2), + topEdge.vertex2, + Vector2(-9.2, -47.1), ); return [ @@ -67,7 +67,7 @@ class _ComputerBaseSpriteComponent extends SpriteComponent with HasGameRef { _ComputerBaseSpriteComponent() : super( anchor: Anchor.center, - position: Vector2(-12.1, -48.15), + position: Vector2(-12.24, -48.15), ); @override @@ -89,7 +89,7 @@ class _ComputerTopSpriteComponent extends SpriteComponent _ComputerTopSpriteComponent() : super( anchor: Anchor.center, - position: Vector2(-12.52, -49.37), + position: Vector2(-12.66, -49.37), ) { zIndex = ZIndexes.computerTop; } @@ -113,9 +113,9 @@ class _ComputerGlowSpriteComponent extends SpriteComponent _ComputerGlowSpriteComponent() : super( anchor: Anchor.center, - position: Vector2(7.4, 10), + position: Vector2(4.2, 11), ) { - zIndex = ZIndexes.computerGlow; + zIndex = ZIndexes.computerGlow + 4; } @override diff --git a/packages/pinball_components/lib/src/components/z_indexes.dart b/packages/pinball_components/lib/src/components/z_indexes.dart index b8371273..b59a9a4b 100644 --- a/packages/pinball_components/lib/src/components/z_indexes.dart +++ b/packages/pinball_components/lib/src/components/z_indexes.dart @@ -45,6 +45,12 @@ abstract class ZIndexes { static const launchRampForegroundRailing = _above + ballOnLaunchRamp; + static const flapperBack = _above + outerBoundary; + + static const flapperFront = _above + flapper; + + static const flapper = _above + ballOnLaunchRamp; + static const plunger = _above + launchRamp; static const rocket = _below + bottomBoundary; @@ -108,5 +114,10 @@ abstract class ZIndexes { static const score = _above + spaceshipRampForegroundRailing; // Debug information + static const debugInfo = _above + score; + + // Backbox + + static const backbox = _below + outerBoundary; } diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 61e62386..4f66c220 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -73,7 +73,6 @@ flutter: - assets/images/sparky/bumper/a/ - assets/images/sparky/bumper/b/ - assets/images/sparky/bumper/c/ - - assets/images/backboard/ - assets/images/google_word/letter1/ - assets/images/google_word/letter2/ - assets/images/google_word/letter3/ @@ -88,6 +87,9 @@ flutter: - assets/images/multiplier/x5/ - assets/images/multiplier/x6/ - assets/images/score/ + - assets/images/backbox/ + - assets/images/flapper/ + - assets/images/skill_shot/ flutter_gen: line_length: 80 diff --git a/packages/pinball_components/sandbox/lib/common/games.dart b/packages/pinball_components/sandbox/lib/common/games.dart index 89d16450..bee6a280 100644 --- a/packages/pinball_components/sandbox/lib/common/games.dart +++ b/packages/pinball_components/sandbox/lib/common/games.dart @@ -24,6 +24,14 @@ abstract class AssetsGame extends Forge2DGame { } abstract class LineGame extends AssetsGame with PanDetector { + LineGame({ + List? imagesFileNames, + }) : super( + imagesFileNames: [ + if (imagesFileNames != null) ...imagesFileNames, + ], + ); + Vector2? _lineEnd; @override diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index cb268b41..ccb1b0bc 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -18,7 +18,6 @@ void main() { addGoogleWordStories(dashbook); addLaunchRampStories(dashbook); addScoreStories(dashbook); - addBackboardStories(dashbook); addMultiballStories(dashbook); addMultipliersStories(dashbook); diff --git a/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_a_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_a_game.dart index 32638c2d..78cebd95 100644 --- a/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_a_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_a_game.dart @@ -7,7 +7,6 @@ import 'package:sandbox/stories/ball/basic_ball_game.dart'; class AndroidBumperAGame extends BallGame { AndroidBumperAGame() : super( - color: const Color(0xFF0000FF), imagesFileNames: [ Assets.images.android.bumper.a.lit.keyName, Assets.images.android.bumper.a.dimmed.keyName, diff --git a/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_b_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_b_game.dart index bfd4206c..9bd2caff 100644 --- a/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_b_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/android_bumper_b_game.dart @@ -7,7 +7,6 @@ import 'package:sandbox/stories/ball/basic_ball_game.dart'; class AndroidBumperBGame extends BallGame { AndroidBumperBGame() : super( - color: const Color(0xFF0000FF), imagesFileNames: [ Assets.images.android.bumper.b.lit.keyName, Assets.images.android.bumper.b.dimmed.keyName, diff --git a/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_rail_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_rail_game.dart index dee83e26..4093ad33 100644 --- a/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_rail_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_rail_game.dart @@ -1,14 +1,12 @@ import 'dart:async'; import 'package:flame/input.dart'; -import 'package:flutter/material.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; class SpaceshipRailGame extends BallGame { SpaceshipRailGame() : super( - color: Colors.blue, ballPriority: ZIndexes.ballOnSpaceshipRail, ballLayer: Layer.spaceshipExitRail, imagesFileNames: [ diff --git a/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart index cabe4d54..fe4e6dae 100644 --- a/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart @@ -9,7 +9,6 @@ import 'package:sandbox/stories/ball/basic_ball_game.dart'; class SpaceshipRampGame extends BallGame with KeyboardEvents { SpaceshipRampGame() : super( - color: Colors.blue, ballPriority: ZIndexes.ballOnSpaceshipRamp, ballLayer: Layer.spaceshipEntranceRamp, imagesFileNames: [ @@ -54,7 +53,7 @@ class SpaceshipRampGame extends BallGame with KeyboardEvents { ) { if (event is RawKeyDownEvent && event.logicalKey == LogicalKeyboardKey.space) { - _spaceshipRamp.progress(); + _spaceshipRamp.bloc.onAscendingBallEntered(); return KeyEventResult.handled; } diff --git a/packages/pinball_components/sandbox/lib/stories/backboard/backboard_game_over_game.dart b/packages/pinball_components/sandbox/lib/stories/backboard/backboard_game_over_game.dart deleted file mode 100644 index ce14d7b6..00000000 --- a/packages/pinball_components/sandbox/lib/stories/backboard/backboard_game_over_game.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flame/input.dart'; -import 'package:pinball_components/pinball_components.dart' as components; -import 'package:pinball_theme/pinball_theme.dart'; -import 'package:sandbox/common/common.dart'; - -class BackboardGameOverGame extends AssetsGame - with HasKeyboardHandlerComponents { - BackboardGameOverGame(this.score, this.character) - : super( - imagesFileNames: [ - components.Assets.images.score.fiveThousand.keyName, - components.Assets.images.score.twentyThousand.keyName, - components.Assets.images.score.twoHundredThousand.keyName, - components.Assets.images.score.oneMillion.keyName, - ...characterIconPaths.values.toList(), - ], - ); - - static const description = ''' - Shows how the Backboard in game over mode is rendered. - - - Select a character to update the character icon. - '''; - - static final characterIconPaths = { - 'Dash': Assets.images.dash.leaderboardIcon.keyName, - 'Sparky': Assets.images.sparky.leaderboardIcon.keyName, - 'Android': Assets.images.android.leaderboardIcon.keyName, - 'Dino': Assets.images.dino.leaderboardIcon.keyName, - }; - - final int score; - - final String character; - - @override - Future onLoad() async { - await super.onLoad(); - - camera - ..followVector2(Vector2.zero()) - ..zoom = 5; - - await add( - components.Backboard.gameOver( - position: Vector2(0, 20), - score: score, - characterIconPath: characterIconPaths[character]!, - onSubmit: (initials) { - add( - components.ScoreComponent( - points: components.Points.values - .firstWhere((element) => element.value == score), - position: Vector2(0, 50), - ), - ); - }, - ), - ); - } -} diff --git a/packages/pinball_components/sandbox/lib/stories/backboard/backboard_waiting_game.dart b/packages/pinball_components/sandbox/lib/stories/backboard/backboard_waiting_game.dart deleted file mode 100644 index 6da9206c..00000000 --- a/packages/pinball_components/sandbox/lib/stories/backboard/backboard_waiting_game.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:sandbox/common/common.dart'; - -class BackboardWaitingGame extends AssetsGame { - BackboardWaitingGame() - : super( - imagesFileNames: [], - ); - - static const description = ''' - Shows how the Backboard in waiting mode is rendered. - '''; - - @override - Future onLoad() async { - camera - ..followVector2(Vector2.zero()) - ..zoom = 5; - - await add( - Backboard.waiting(position: Vector2(0, 20)), - ); - } -} diff --git a/packages/pinball_components/sandbox/lib/stories/backboard/stories.dart b/packages/pinball_components/sandbox/lib/stories/backboard/stories.dart deleted file mode 100644 index 9e83c7c4..00000000 --- a/packages/pinball_components/sandbox/lib/stories/backboard/stories.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:dashbook/dashbook.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:sandbox/common/common.dart'; -import 'package:sandbox/stories/backboard/backboard_game_over_game.dart'; -import 'package:sandbox/stories/backboard/backboard_waiting_game.dart'; - -void addBackboardStories(Dashbook dashbook) { - dashbook.storiesOf('Backboard') - ..addGame( - title: 'Waiting', - description: BackboardWaitingGame.description, - gameBuilder: (_) => BackboardWaitingGame(), - ) - ..addGame( - title: 'Game over', - description: BackboardGameOverGame.description, - gameBuilder: (context) => BackboardGameOverGame( - context.listProperty( - 'Score', - Points.values.first.value, - Points.values.map((score) => score.value).toList(), - ), - context.listProperty( - 'Character', - BackboardGameOverGame.characterIconPaths.keys.first, - BackboardGameOverGame.characterIconPaths.keys.toList(), - ), - ), - ); -} diff --git a/packages/pinball_components/sandbox/lib/stories/ball/ball_booster_game.dart b/packages/pinball_components/sandbox/lib/stories/ball/ball_booster_game.dart index 7f07de97..ac0989e2 100644 --- a/packages/pinball_components/sandbox/lib/stories/ball/ball_booster_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/ball/ball_booster_game.dart @@ -1,9 +1,20 @@ import 'package:flame/components.dart'; -import 'package:flutter/material.dart'; import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import 'package:sandbox/common/common.dart'; class BallBoosterGame extends LineGame { + BallBoosterGame() + : super( + imagesFileNames: [ + theme.Assets.images.android.ball.keyName, + theme.Assets.images.dash.ball.keyName, + theme.Assets.images.dino.ball.keyName, + theme.Assets.images.sparky.ball.keyName, + Assets.images.ball.flameEffect.keyName, + ], + ); + static const description = ''' Shows how a Ball with a boost works. @@ -12,9 +23,10 @@ class BallBoosterGame extends LineGame { @override void onLine(Vector2 line) { - final ball = Ball(baseColor: Colors.transparent); - add(ball); + final ball = Ball(); + final impulse = line * -1 * 20; + ball.add(BallTurboChargingBehavior(impulse: impulse)); - ball.mounted.then((value) => ball.boost(line * -1 * 20)); + add(ball); } } diff --git a/packages/pinball_components/sandbox/lib/stories/ball/basic_ball_game.dart b/packages/pinball_components/sandbox/lib/stories/ball/basic_ball_game.dart index e57a0322..f3ba50f3 100644 --- a/packages/pinball_components/sandbox/lib/stories/ball/basic_ball_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/ball/basic_ball_game.dart @@ -1,17 +1,20 @@ import 'package:flame/input.dart'; -import 'package:flutter/material.dart'; import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import 'package:sandbox/common/common.dart'; class BallGame extends AssetsGame with TapDetector, Traceable { BallGame({ - this.color = Colors.blue, this.ballPriority = 0, this.ballLayer = Layer.all, + this.character, List? imagesFileNames, }) : super( imagesFileNames: [ - Assets.images.ball.ball.keyName, + theme.Assets.images.android.ball.keyName, + theme.Assets.images.dash.ball.keyName, + theme.Assets.images.dino.ball.keyName, + theme.Assets.images.sparky.ball.keyName, if (imagesFileNames != null) ...imagesFileNames, ], ); @@ -22,14 +25,23 @@ class BallGame extends AssetsGame with TapDetector, Traceable { - Tap anywhere on the screen to spawn a ball into the game. '''; - final Color color; + static final characterBallPaths = { + 'Dash': theme.Assets.images.dash.ball.keyName, + 'Sparky': theme.Assets.images.sparky.ball.keyName, + 'Android': theme.Assets.images.android.ball.keyName, + 'Dino': theme.Assets.images.dino.ball.keyName, + }; + final int ballPriority; final Layer ballLayer; + final String? character; @override void onTapUp(TapUpInfo info) { add( - Ball(baseColor: color) + Ball( + assetPath: characterBallPaths[character], + ) ..initialPosition = info.eventPosition.game ..layer = ballLayer ..priority = ballPriority, diff --git a/packages/pinball_components/sandbox/lib/stories/ball/stories.dart b/packages/pinball_components/sandbox/lib/stories/ball/stories.dart index eb472282..146ebcda 100644 --- a/packages/pinball_components/sandbox/lib/stories/ball/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/ball/stories.dart @@ -1,5 +1,4 @@ import 'package:dashbook/dashbook.dart'; -import 'package:flutter/material.dart'; import 'package:sandbox/common/common.dart'; import 'package:sandbox/stories/ball/ball_booster_game.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; @@ -7,10 +6,14 @@ import 'package:sandbox/stories/ball/basic_ball_game.dart'; void addBallStories(Dashbook dashbook) { dashbook.storiesOf('Ball') ..addGame( - title: 'Colored', + title: 'Themed', description: BallGame.description, gameBuilder: (context) => BallGame( - color: context.colorProperty('color', Colors.blue), + character: context.listProperty( + 'Character', + BallGame.characterBallPaths.keys.first, + BallGame.characterBallPaths.keys.toList(), + ), ), ) ..addGame( 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 bc537de2..94389f60 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 @@ -1,14 +1,10 @@ -import 'dart:ui'; - import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/material.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; class GoogleLetterGame extends BallGame { GoogleLetterGame() : super( - color: const Color(0xFF009900), imagesFileNames: [ Assets.images.googleWord.letter1.lit.keyName, Assets.images.googleWord.letter1.dimmed.keyName, diff --git a/packages/pinball_components/sandbox/lib/stories/launch_ramp/launch_ramp_game.dart b/packages/pinball_components/sandbox/lib/stories/launch_ramp/launch_ramp_game.dart index ea3bd4db..b6955a26 100644 --- a/packages/pinball_components/sandbox/lib/stories/launch_ramp/launch_ramp_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/launch_ramp/launch_ramp_game.dart @@ -1,14 +1,12 @@ import 'dart:async'; import 'package:flame/input.dart'; -import 'package:flutter/material.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; class LaunchRampGame extends BallGame { LaunchRampGame() : super( - color: Colors.blue, ballPriority: ZIndexes.ballOnLaunchRamp, ballLayer: Layer.launcher, ); diff --git a/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart b/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart index 0f1ec2e4..0ee58cc9 100644 --- a/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart @@ -6,8 +6,6 @@ import 'package:sandbox/common/common.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; class PlungerGame extends BallGame with KeyboardEvents, Traceable { - PlungerGame() : super(color: const Color(0xFFFF0000)); - static const description = ''' Shows how Plunger is rendered. diff --git a/packages/pinball_components/sandbox/lib/stories/score/score_game.dart b/packages/pinball_components/sandbox/lib/stories/score/score_game.dart index 4bde5018..edb4fa36 100644 --- a/packages/pinball_components/sandbox/lib/stories/score/score_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/score/score_game.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:flame/effects.dart'; import 'package:flame/input.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:sandbox/common/common.dart'; @@ -38,6 +39,7 @@ class ScoreGame extends AssetsGame with TapDetector { ScoreComponent( points: score, position: info.eventPosition.game..multiply(Vector2(1, -1)), + effectController: EffectController(duration: 1), ), ); } diff --git a/packages/pinball_components/sandbox/lib/stories/stories.dart b/packages/pinball_components/sandbox/lib/stories/stories.dart index b8bc567c..b48770ba 100644 --- a/packages/pinball_components/sandbox/lib/stories/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/stories.dart @@ -1,5 +1,4 @@ export 'android_acres/stories.dart'; -export 'backboard/stories.dart'; export 'ball/stories.dart'; export 'bottom_group/stories.dart'; export 'boundaries/stories.dart'; diff --git a/packages/pinball_components/test/src/components/backboard_test.dart b/packages/pinball_components/test/src/components/backboard_test.dart deleted file mode 100644 index aee2481a..00000000 --- a/packages/pinball_components/test/src/components/backboard_test.dart +++ /dev/null @@ -1,185 +0,0 @@ -// ignore_for_file: unawaited_futures, cascade_invocations - -import 'package:flame/components.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball_components/pinball_components.dart' hide Assets; -import 'package:pinball_theme/pinball_theme.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - group('Backboard', () { - final characterIconPath = Assets.images.dash.leaderboardIcon.keyName; - final tester = FlameTester(() => KeyboardTestGame([characterIconPath])); - - group('on waitingMode', () { - tester.testGameWidget( - 'renders correctly', - setUp: (game, tester) async { - game.camera.zoom = 2; - game.camera.followVector2(Vector2.zero()); - await game.ensureAdd(Backboard.waiting(position: Vector2(0, 15))); - await tester.pump(); - }, - verify: (game, tester) async { - await expectLater( - find.byGame(), - matchesGoldenFile('golden/backboard/waiting.png'), - ); - }, - ); - }); - - group('on gameOverMode', () { - tester.testGameWidget( - 'renders correctly', - setUp: (game, tester) async { - game.camera.zoom = 2; - game.camera.followVector2(Vector2.zero()); - final backboard = Backboard.gameOver( - position: Vector2(0, 15), - score: 1000, - characterIconPath: characterIconPath, - onSubmit: (_) {}, - ); - await game.ensureAdd(backboard); - }, - verify: (game, tester) async { - final prompts = - game.descendants().whereType().length; - expect(prompts, equals(3)); - - final score = game.descendants().firstWhere( - (component) => - component is TextComponent && component.text == '1,000', - ); - expect(score, isNotNull); - }, - ); - - tester.testGameWidget( - 'can change the initials', - setUp: (game, tester) async { - final backboard = Backboard.gameOver( - position: Vector2(0, 15), - score: 1000, - characterIconPath: characterIconPath, - onSubmit: (_) {}, - ); - await game.ensureAdd(backboard); - - // Focus is already on the first letter - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - // Move to the next an press up again - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - // One more time - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - // Back to the previous and increase one more - await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - }, - verify: (game, tester) async { - final backboard = game - .descendants() - .firstWhere((component) => component is BackboardGameOver) - as BackboardGameOver; - - expect(backboard.initials, equals('BCB')); - }, - ); - - String? submitedInitials; - tester.testGameWidget( - 'submits the initials', - setUp: (game, tester) async { - final backboard = Backboard.gameOver( - position: Vector2(0, 15), - score: 1000, - characterIconPath: characterIconPath, - onSubmit: (value) { - submitedInitials = value; - }, - ); - await game.ensureAdd(backboard); - - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - }, - verify: (game, tester) async { - expect(submitedInitials, equals('AAA')); - }, - ); - }); - }); - - group('BackboardLetterPrompt', () { - final tester = FlameTester(KeyboardTestGame.new); - - tester.testGameWidget( - 'cycles the char up and down when it has focus', - setUp: (game, tester) async { - await game.ensureAdd( - BackboardLetterPrompt(hasFocus: true, position: Vector2.zero()), - ); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - }, - verify: (game, tester) async { - final prompt = game.firstChild(); - expect(prompt?.char, equals('C')); - }, - ); - - tester.testGameWidget( - "does nothing when it doesn't have focus", - setUp: (game, tester) async { - await game.ensureAdd( - BackboardLetterPrompt(position: Vector2.zero()), - ); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - }, - verify: (game, tester) async { - final prompt = game.firstChild(); - expect(prompt?.char, equals('A')); - }, - ); - - tester.testGameWidget( - 'blinks the prompt when it has the focus', - setUp: (game, tester) async { - await game.ensureAdd( - BackboardLetterPrompt(position: Vector2.zero(), hasFocus: true), - ); - }, - verify: (game, tester) async { - final underscore = game.descendants().whereType().first; - expect(underscore.paint.color, Colors.white); - - game.update(2); - expect(underscore.paint.color, Colors.transparent); - }, - ); - }); -} diff --git a/packages/pinball_components/test/src/components/ball/ball_test.dart b/packages/pinball_components/test/src/components/ball/ball_test.dart index 02175f16..9195e0b2 100644 --- a/packages/pinball_components/test/src/components/ball/ball_test.dart +++ b/packages/pinball_components/test/src/components/ball/ball_test.dart @@ -1,34 +1,37 @@ // ignore_for_file: cascade_invocations -import 'package:flame/components.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:pinball_components/pinball_components.dart'; -import 'package:pinball_components/src/components/ball/behaviors/behaviors.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(TestGame.new); + final assets = [ + theme.Assets.images.android.ball.keyName, + theme.Assets.images.dash.ball.keyName, + theme.Assets.images.dino.ball.keyName, + theme.Assets.images.sparky.ball.keyName, + ]; - group('Ball', () { - const baseColor = Color(0xFFFFFFFF); + final flameTester = FlameTester(() => TestGame(assets)); + group('Ball', () { test( 'can be instantiated', () { - expect(Ball(baseColor: baseColor), isA()); - expect(Ball.test(baseColor: baseColor), isA()); + expect(Ball(), isA()); + expect(Ball.test(), isA()); }, ); flameTester.test( 'loads correctly', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ready(); await game.ensureAdd(ball); @@ -38,7 +41,7 @@ void main() { group('adds', () { flameTester.test('a BallScalingBehavior', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); expect( ball.descendants().whereType().length, @@ -47,7 +50,7 @@ void main() { }); flameTester.test('a BallGravitatingBehavior', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); expect( ball.descendants().whereType().length, @@ -60,7 +63,7 @@ void main() { flameTester.test( 'is dynamic', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); expect(ball.body.bodyType, equals(BodyType.dynamic)); @@ -69,7 +72,7 @@ void main() { group('can be moved', () { flameTester.test('by its weight', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); game.update(1); @@ -77,7 +80,7 @@ void main() { }); flameTester.test('by applying velocity', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); ball.body.gravityScale = Vector2.zero(); @@ -92,7 +95,7 @@ void main() { flameTester.test( 'exists', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); expect(ball.body.fixtures[0], isA()); @@ -102,7 +105,7 @@ void main() { flameTester.test( 'is dense', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); final fixture = ball.body.fixtures[0]; @@ -113,7 +116,7 @@ void main() { flameTester.test( 'shape is circular', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); final fixture = ball.body.fixtures[0]; @@ -125,7 +128,7 @@ void main() { flameTester.test( 'has Layer.all as default filter maskBits', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ready(); await game.ensureAdd(ball); await game.ready(); @@ -139,7 +142,7 @@ void main() { group('stop', () { group("can't be moved", () { flameTester.test('by its weight', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); ball.stop(); @@ -154,7 +157,7 @@ void main() { flameTester.test( 'by its weight when previously stopped', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); ball.stop(); ball.resume(); @@ -167,7 +170,7 @@ void main() { flameTester.test( 'by applying velocity when previously stopped', (game) async { - final ball = Ball(baseColor: baseColor); + final ball = Ball(); await game.ensureAdd(ball); ball.stop(); ball.resume(); @@ -180,50 +183,5 @@ void main() { ); }); }); - - group('boost', () { - flameTester.test('applies an impulse to the ball', (game) async { - final ball = Ball(baseColor: baseColor); - await game.ensureAdd(ball); - - expect(ball.body.linearVelocity, equals(Vector2.zero())); - - await ball.boost(Vector2.all(10)); - expect(ball.body.linearVelocity.x, greaterThan(0)); - expect(ball.body.linearVelocity.y, greaterThan(0)); - }); - - flameTester.test('adds TurboChargeSpriteAnimation', (game) async { - final ball = Ball(baseColor: baseColor); - await game.ensureAdd(ball); - - await ball.boost(Vector2.all(10)); - game.update(0); - - expect( - ball.children.whereType().single, - isNotNull, - ); - }); - - flameTester.test('removes TurboChargeSpriteAnimation after it finishes', - (game) async { - final ball = Ball(baseColor: baseColor); - await game.ensureAdd(ball); - - await ball.boost(Vector2.all(10)); - game.update(0); - - final turboChargeSpriteAnimation = - ball.children.whereType().single; - - expect(ball.contains(turboChargeSpriteAnimation), isTrue); - - game.update(turboChargeSpriteAnimation.animation!.totalDuration()); - game.update(0.1); - - expect(ball.contains(turboChargeSpriteAnimation), isFalse); - }); - }); }); } diff --git a/packages/pinball_components/test/src/components/ball/behaviors/ball_gravitating_behavior_test.dart b/packages/pinball_components/test/src/components/ball/behaviors/ball_gravitating_behavior_test.dart index de291f21..ce193dc8 100644 --- a/packages/pinball_components/test/src/components/ball/behaviors/ball_gravitating_behavior_test.dart +++ b/packages/pinball_components/test/src/components/ball/behaviors/ball_gravitating_behavior_test.dart @@ -1,22 +1,19 @@ // ignore_for_file: cascade_invocations -import 'dart:ui'; - 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 'package:pinball_components/src/components/ball/behaviors/behaviors.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final asset = Assets.images.ball.ball.keyName; + final asset = theme.Assets.images.dash.ball.keyName; final flameTester = FlameTester(() => TestGame([asset])); group('BallGravitatingBehavior', () { - const baseColor = Color(0xFFFFFFFF); test('can be instantiated', () { expect( BallGravitatingBehavior(), @@ -25,7 +22,7 @@ void main() { }); flameTester.test('can be loaded', (game) async { - final ball = Ball.test(baseColor: baseColor); + final ball = Ball.test(); final behavior = BallGravitatingBehavior(); await ball.add(behavior); await game.ensureAdd(ball); @@ -38,12 +35,10 @@ void main() { flameTester.test( "overrides the body's horizontal gravity symmetrically", (game) async { - final ball1 = Ball.test(baseColor: baseColor) - ..initialPosition = Vector2(10, 0); + final ball1 = Ball.test()..initialPosition = Vector2(10, 0); await ball1.add(BallGravitatingBehavior()); - final ball2 = Ball.test(baseColor: baseColor) - ..initialPosition = Vector2(-10, 0); + final ball2 = Ball.test()..initialPosition = Vector2(-10, 0); await ball2.add(BallGravitatingBehavior()); await game.ensureAddAll([ball1, ball2]); diff --git a/packages/pinball_components/test/src/components/ball/behaviors/ball_scaling_behavior_test.dart b/packages/pinball_components/test/src/components/ball/behaviors/ball_scaling_behavior_test.dart index cd0a0486..bd0cca49 100644 --- a/packages/pinball_components/test/src/components/ball/behaviors/ball_scaling_behavior_test.dart +++ b/packages/pinball_components/test/src/components/ball/behaviors/ball_scaling_behavior_test.dart @@ -1,22 +1,19 @@ // ignore_for_file: cascade_invocations -import 'dart:ui'; - 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 'package:pinball_components/src/components/ball/behaviors/behaviors.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final asset = Assets.images.ball.ball.keyName; + final asset = theme.Assets.images.dash.ball.keyName; final flameTester = FlameTester(() => TestGame([asset])); group('BallScalingBehavior', () { - const baseColor = Color(0xFFFFFFFF); test('can be instantiated', () { expect( BallScalingBehavior(), @@ -25,7 +22,7 @@ void main() { }); flameTester.test('can be loaded', (game) async { - final ball = Ball.test(baseColor: baseColor); + final ball = Ball.test(); final behavior = BallScalingBehavior(); await ball.add(behavior); await game.ensureAdd(ball); @@ -36,12 +33,10 @@ void main() { }); flameTester.test('scales the shape radius', (game) async { - final ball1 = Ball.test(baseColor: baseColor) - ..initialPosition = Vector2(0, 10); + final ball1 = Ball.test()..initialPosition = Vector2(0, 10); await ball1.add(BallScalingBehavior()); - final ball2 = Ball.test(baseColor: baseColor) - ..initialPosition = Vector2(0, -10); + final ball2 = Ball.test()..initialPosition = Vector2(0, -10); await ball2.add(BallScalingBehavior()); await game.ensureAddAll([ball1, ball2]); @@ -58,12 +53,10 @@ void main() { flameTester.test( 'scales the sprite', (game) async { - final ball1 = Ball.test(baseColor: baseColor) - ..initialPosition = Vector2(0, 10); + final ball1 = Ball.test()..initialPosition = Vector2(0, 10); await ball1.add(BallScalingBehavior()); - final ball2 = Ball.test(baseColor: baseColor) - ..initialPosition = Vector2(0, -10); + final ball2 = Ball.test()..initialPosition = Vector2(0, -10); await ball2.add(BallScalingBehavior()); await game.ensureAddAll([ball1, ball2]); diff --git a/packages/pinball_components/test/src/components/ball/behaviors/ball_turbo_charging_behavior_test.dart b/packages/pinball_components/test/src/components/ball/behaviors/ball_turbo_charging_behavior_test.dart new file mode 100644 index 00000000..79eb030e --- /dev/null +++ b/packages/pinball_components/test/src/components/ball/behaviors/ball_turbo_charging_behavior_test.dart @@ -0,0 +1,91 @@ +// 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 'package:pinball_theme/pinball_theme.dart' as theme; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'BallTurboChargingBehavior', + () { + final asset = theme.Assets.images.dash.ball.keyName; + final flameTester = FlameTester(() => TestGame([asset])); + + test('can be instantiated', () { + expect( + BallTurboChargingBehavior(impulse: Vector2.zero()), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final ball = Ball.test(); + final behavior = BallTurboChargingBehavior(impulse: Vector2.zero()); + await ball.add(behavior); + await game.ensureAdd(ball); + expect( + ball.firstChild(), + equals(behavior), + ); + }); + + flameTester.test( + 'impulses the ball velocity when loaded', + (game) async { + final ball = Ball.test(); + await game.ensureAdd(ball); + final impulse = Vector2.all(1); + final behavior = BallTurboChargingBehavior(impulse: impulse); + await ball.ensureAdd(behavior); + + expect( + ball.body.linearVelocity.x, + equals(impulse.x), + ); + expect( + ball.body.linearVelocity.y, + equals(impulse.y), + ); + }, + ); + + flameTester.test('adds sprite', (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + + await ball.ensureAdd( + BallTurboChargingBehavior(impulse: Vector2.zero()), + ); + + expect( + ball.children.whereType().single, + isNotNull, + ); + }); + + flameTester.test('removes sprite after it finishes', (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + + final behavior = BallTurboChargingBehavior(impulse: Vector2.zero()); + await ball.ensureAdd(behavior); + + final turboChargeSpriteAnimation = + ball.children.whereType().single; + + expect(ball.contains(turboChargeSpriteAnimation), isTrue); + + game.update(behavior.timer.limit); + game.update(0.1); + + expect(ball.contains(turboChargeSpriteAnimation), isFalse); + }); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_chomping_behavior_test.dart b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_chomping_behavior_test.dart index 8d052fab..dfc33967 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_chomping_behavior_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_chomping_behavior_test.dart @@ -4,11 +4,11 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.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/chrome_dino/behaviors/behaviors.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../../../helpers/helpers.dart'; @@ -20,7 +20,10 @@ class _MockFixture extends Mock implements Fixture {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(TestGame.new); + final assets = [ + theme.Assets.images.dash.ball.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); group( 'ChromeDinoChompingBehavior', @@ -35,7 +38,7 @@ void main() { flameTester.test( 'beginContact sets ball sprite to be invisible and calls onChomp', (game) async { - final ball = Ball(baseColor: Colors.red); + final ball = Ball(); final behavior = ChromeDinoChompingBehavior(); final bloc = _MockChromeDinoCubit(); whenListen( diff --git a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_spitting_behavior_test.dart b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_spitting_behavior_test.dart index 1d0a55b4..8c2cbe57 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_spitting_behavior_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_spitting_behavior_test.dart @@ -5,11 +5,11 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.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/chrome_dino/behaviors/behaviors.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../../../helpers/helpers.dart'; @@ -17,7 +17,10 @@ class _MockChromeDinoCubit extends Mock implements ChromeDinoCubit {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(TestGame.new); + final assets = [ + theme.Assets.images.dash.ball.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); group( 'ChromeDinoSpittingBehavior', @@ -33,7 +36,7 @@ void main() { flameTester.test( 'sets ball sprite to visible and sets a linear velocity', (game) async { - final ball = Ball(baseColor: Colors.red); + final ball = Ball(); final behavior = ChromeDinoSpittingBehavior(); final bloc = _MockChromeDinoCubit(); final streamController = StreamController(); @@ -71,7 +74,7 @@ void main() { flameTester.test( 'calls onSpit', (game) async { - final ball = Ball(baseColor: Colors.red); + final ball = Ball(); final behavior = ChromeDinoSpittingBehavior(); final bloc = _MockChromeDinoCubit(); final streamController = StreamController(); diff --git a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart index 9b6a05b6..4b34940c 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart @@ -36,7 +36,7 @@ void main() { whenListen( bloc, const Stream.empty(), - initialState: const ChromeDinoState.inital(), + initialState: const ChromeDinoState.initial(), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -58,7 +58,7 @@ void main() { whenListen( bloc, const Stream.empty(), - initialState: const ChromeDinoState.inital(), + initialState: const ChromeDinoState.initial(), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -91,7 +91,7 @@ void main() { bloc, const Stream.empty(), initialState: - const ChromeDinoState.inital().copyWith(isMouthOpen: true), + const ChromeDinoState.initial().copyWith(isMouthOpen: true), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -120,7 +120,7 @@ void main() { bloc, const Stream.empty(), initialState: - const ChromeDinoState.inital().copyWith(isMouthOpen: false), + const ChromeDinoState.initial().copyWith(isMouthOpen: false), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -148,7 +148,7 @@ void main() { bloc, const Stream.empty(), initialState: - const ChromeDinoState.inital().copyWith(isMouthOpen: false), + const ChromeDinoState.initial().copyWith(isMouthOpen: false), ); final chromeDino = ChromeDino.test(bloc: bloc); diff --git a/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart b/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart index 4c1802ef..d6366092 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart @@ -79,7 +79,7 @@ void main() { whenListen( bloc, const Stream.empty(), - initialState: const ChromeDinoState.inital(), + initialState: const ChromeDinoState.initial(), ); when(bloc.close).thenAnswer((_) async {}); final chromeDino = ChromeDino.test(bloc: bloc); diff --git a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart index 5b31be74..80c01983 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart @@ -1,5 +1,4 @@ import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -7,7 +6,7 @@ void main() { group( 'ChromeDinoCubit', () { - final ball = Ball(baseColor: Colors.red); + final ball = Ball(); blocTest( 'onOpenMouth emits true', @@ -58,7 +57,7 @@ void main() { blocTest( 'onChomp emits nothing when the ball is already in the mouth', build: ChromeDinoCubit.new, - seed: () => const ChromeDinoState.inital().copyWith(ball: ball), + seed: () => const ChromeDinoState.initial().copyWith(ball: ball), act: (bloc) => bloc.onChomp(ball), expect: () => [], ); diff --git a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart index d067674b..0d7f9c83 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart @@ -1,6 +1,5 @@ // ignore_for_file: prefer_const_constructors -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -37,7 +36,7 @@ void main() { status: ChromeDinoStatus.idle, isMouthOpen: false, ); - expect(ChromeDinoState.inital(), equals(initialState)); + expect(ChromeDinoState.initial(), equals(initialState)); }); }); @@ -61,7 +60,7 @@ void main() { 'copies correctly ' 'when all arguments specified', () { - final ball = Ball(baseColor: Colors.red); + final ball = Ball(); const chromeDinoState = ChromeDinoState( status: ChromeDinoStatus.chomping, isMouthOpen: true, diff --git a/packages/pinball_components/test/src/components/flapper/behaviors/flapper_spinning_behavior_test.dart b/packages/pinball_components/test/src/components/flapper/behaviors/flapper_spinning_behavior_test.dart new file mode 100644 index 00000000..c53dc17b --- /dev/null +++ b/packages/pinball_components/test/src/components/flapper/behaviors/flapper_spinning_behavior_test.dart @@ -0,0 +1,53 @@ +// 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/pinball_components.dart'; +import 'package:pinball_components/src/components/flapper/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockBall extends Mock implements Ball {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.flapper.flap.keyName, + Assets.images.flapper.backSupport.keyName, + Assets.images.flapper.frontSupport.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + group( + 'FlapperSpinningBehavior', + () { + test('can be instantiated', () { + expect( + FlapperSpinningBehavior(), + isA(), + ); + }); + + flameTester.test( + 'beginContact plays the flapper animation', + (game) async { + final behavior = FlapperSpinningBehavior(); + final entrance = FlapperEntrance(); + final flap = FlapSpriteAnimationComponent(); + final flapper = Flapper.test(); + await flapper.addAll([entrance, flap]); + await entrance.add(behavior); + await game.ensureAdd(flapper); + + behavior.beginContact(_MockBall(), _MockContact()); + + expect(flap.playing, isTrue); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/flapper/flapper_test.dart b/packages/pinball_components/test/src/components/flapper/flapper_test.dart new file mode 100644 index 00000000..497bb5f6 --- /dev/null +++ b/packages/pinball_components/test/src/components/flapper/flapper_test.dart @@ -0,0 +1,100 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/flapper/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + group('Flapper', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.flapper.flap.keyName, + Assets.images.flapper.backSupport.keyName, + Assets.images.flapper.frontSupport.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + flameTester.test('loads correctly', (game) async { + final component = Flapper(); + await game.ensureAdd(component); + expect(game.contains(component), isTrue); + }); + + flameTester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + await game.images.loadAll(assets); + final canvas = ZCanvasComponent(children: [Flapper()]); + await game.ensureAdd(canvas); + game.camera + ..followVector2(Vector2(3, -70)) + ..zoom = 25; + await tester.pump(); + }, + verify: (game, tester) async { + const goldenFilePath = '../golden/flapper/'; + final flapSpriteAnimationComponent = game + .descendants() + .whereType() + .first + ..playing = true; + final animationDuration = + flapSpriteAnimationComponent.animation!.totalDuration(); + + await expectLater( + find.byGame(), + matchesGoldenFile('${goldenFilePath}start.png'), + ); + + game.update(animationDuration * 0.25); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('${goldenFilePath}middle.png'), + ); + + game.update(animationDuration * 0.75); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('${goldenFilePath}end.png'), + ); + }, + ); + + flameTester.test('adds a FlapperSpiningBehavior to FlapperEntrance', + (game) async { + final flapper = Flapper(); + await game.ensureAdd(flapper); + + final flapperEntrance = flapper.firstChild()!; + expect( + flapperEntrance.firstChild(), + isNotNull, + ); + }); + + flameTester.test( + 'flap stops animating after animation completes', + (game) async { + final flapper = Flapper(); + await game.ensureAdd(flapper); + + final flapSpriteAnimationComponent = + flapper.firstChild()!; + + flapSpriteAnimationComponent.playing = true; + game.update( + flapSpriteAnimationComponent.animation!.totalDuration() + 0.1, + ); + + expect(flapSpriteAnimationComponent.playing, isFalse); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/flipper_test.dart b/packages/pinball_components/test/src/components/flipper_test.dart index c34d0d1c..314b1f77 100644 --- a/packages/pinball_components/test/src/components/flipper_test.dart +++ b/packages/pinball_components/test/src/components/flipper_test.dart @@ -2,9 +2,9 @@ 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:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../helpers/helpers.dart'; @@ -13,6 +13,7 @@ void main() { final assets = [ Assets.images.flipper.left.keyName, Assets.images.flipper.right.keyName, + theme.Assets.images.dash.ball.keyName, ]; final flameTester = FlameTester(() => TestGame(assets)); @@ -89,7 +90,7 @@ void main() { 'has greater mass than Ball', (game) async { final flipper = Flipper(side: BoardSide.left); - final ball = Ball(baseColor: Colors.white); + final ball = Ball(); await game.ready(); await game.ensureAddAll([flipper, ball]); diff --git a/packages/pinball_components/test/src/components/golden/android_animatronic/end.png b/packages/pinball_components/test/src/components/golden/android_animatronic/end.png index 3d54999f..d6ddbb68 100644 Binary files a/packages/pinball_components/test/src/components/golden/android_animatronic/end.png and b/packages/pinball_components/test/src/components/golden/android_animatronic/end.png differ diff --git a/packages/pinball_components/test/src/components/golden/android_animatronic/middle.png b/packages/pinball_components/test/src/components/golden/android_animatronic/middle.png index 44916338..00f4da13 100644 Binary files a/packages/pinball_components/test/src/components/golden/android_animatronic/middle.png and b/packages/pinball_components/test/src/components/golden/android_animatronic/middle.png differ diff --git a/packages/pinball_components/test/src/components/golden/android_animatronic/start.png b/packages/pinball_components/test/src/components/golden/android_animatronic/start.png index 95580e91..31d8bfdf 100644 Binary files a/packages/pinball_components/test/src/components/golden/android_animatronic/start.png and b/packages/pinball_components/test/src/components/golden/android_animatronic/start.png differ diff --git a/packages/pinball_components/test/src/components/golden/android_spaceship/end.png b/packages/pinball_components/test/src/components/golden/android_spaceship/end.png index a64b4724..ce8cdd24 100644 Binary files a/packages/pinball_components/test/src/components/golden/android_spaceship/end.png and b/packages/pinball_components/test/src/components/golden/android_spaceship/end.png differ diff --git a/packages/pinball_components/test/src/components/golden/android_spaceship/middle.png b/packages/pinball_components/test/src/components/golden/android_spaceship/middle.png index 90361e22..49f6c9ce 100644 Binary files a/packages/pinball_components/test/src/components/golden/android_spaceship/middle.png and b/packages/pinball_components/test/src/components/golden/android_spaceship/middle.png differ diff --git a/packages/pinball_components/test/src/components/golden/android_spaceship/start.png b/packages/pinball_components/test/src/components/golden/android_spaceship/start.png index 649a8654..eb109628 100644 Binary files a/packages/pinball_components/test/src/components/golden/android_spaceship/start.png and b/packages/pinball_components/test/src/components/golden/android_spaceship/start.png differ diff --git a/packages/pinball_components/test/src/components/golden/backboard/waiting.png b/packages/pinball_components/test/src/components/golden/backboard/waiting.png deleted file mode 100644 index 00164289..00000000 Binary files a/packages/pinball_components/test/src/components/golden/backboard/waiting.png and /dev/null differ diff --git a/packages/pinball_components/test/src/components/golden/ball/android.png b/packages/pinball_components/test/src/components/golden/ball/android.png new file mode 100644 index 00000000..2a659092 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/ball/android.png differ diff --git a/packages/pinball_components/test/src/components/golden/ball/dash.png b/packages/pinball_components/test/src/components/golden/ball/dash.png new file mode 100644 index 00000000..c95afc88 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/ball/dash.png differ diff --git a/packages/pinball_components/test/src/components/golden/ball/dino.png b/packages/pinball_components/test/src/components/golden/ball/dino.png new file mode 100644 index 00000000..8ea10758 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/ball/dino.png differ diff --git a/packages/pinball_components/test/src/components/golden/ball/sparky.png b/packages/pinball_components/test/src/components/golden/ball/sparky.png new file mode 100644 index 00000000..afdeb263 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/ball/sparky.png differ diff --git a/packages/pinball_components/test/src/components/golden/boundaries.png b/packages/pinball_components/test/src/components/golden/boundaries.png index 9e9b5633..f2987360 100644 Binary files a/packages/pinball_components/test/src/components/golden/boundaries.png and b/packages/pinball_components/test/src/components/golden/boundaries.png differ diff --git a/packages/pinball_components/test/src/components/golden/flapper/end.png b/packages/pinball_components/test/src/components/golden/flapper/end.png new file mode 100644 index 00000000..31319b37 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/flapper/end.png differ diff --git a/packages/pinball_components/test/src/components/golden/flapper/middle.png b/packages/pinball_components/test/src/components/golden/flapper/middle.png new file mode 100644 index 00000000..4f0484f3 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/flapper/middle.png differ diff --git a/packages/pinball_components/test/src/components/golden/flapper/start.png b/packages/pinball_components/test/src/components/golden/flapper/start.png new file mode 100644 index 00000000..e6da466a Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/flapper/start.png differ diff --git a/packages/pinball_components/test/src/components/golden/rocket.png b/packages/pinball_components/test/src/components/golden/rocket.png index 62ba4e61..f9dc36f8 100644 Binary files a/packages/pinball_components/test/src/components/golden/rocket.png and b/packages/pinball_components/test/src/components/golden/rocket.png differ diff --git a/packages/pinball_components/test/src/components/golden/sparky-computer.png b/packages/pinball_components/test/src/components/golden/sparky-computer.png index 1ade03c2..9567d231 100644 Binary files a/packages/pinball_components/test/src/components/golden/sparky-computer.png and b/packages/pinball_components/test/src/components/golden/sparky-computer.png differ diff --git a/packages/pinball_components/test/src/components/launch_ramp_test.dart b/packages/pinball_components/test/src/components/launch_ramp_test.dart index 44fa8609..38c0920b 100644 --- a/packages/pinball_components/test/src/components/launch_ramp_test.dart +++ b/packages/pinball_components/test/src/components/launch_ramp_test.dart @@ -9,7 +9,13 @@ import '../../helpers/helpers.dart'; void main() { group('LaunchRamp', () { - final flameTester = FlameTester(TestGame.new); + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.launchRamp.ramp.keyName, + Assets.images.launchRamp.backgroundRailing.keyName, + Assets.images.launchRamp.foregroundRailing.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); flameTester.test('loads correctly', (game) async { final component = LaunchRamp(); @@ -20,9 +26,12 @@ void main() { flameTester.testGameWidget( 'renders correctly', setUp: (game, tester) async { + await game.images.loadAll(assets); await game.ensureAdd(LaunchRamp()); game.camera.followVector2(Vector2.zero()); game.camera.zoom = 4.1; + await game.ready(); + await tester.pump(); }, verify: (game, tester) async { await expectLater( diff --git a/packages/pinball_components/test/src/components/layer_sensor/behavior/layer_filtering_behavior_test.dart b/packages/pinball_components/test/src/components/layer_sensor/behavior/layer_filtering_behavior_test.dart new file mode 100644 index 00000000..b7bc308b --- /dev/null +++ b/packages/pinball_components/test/src/components/layer_sensor/behavior/layer_filtering_behavior_test.dart @@ -0,0 +1,136 @@ +// 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/pinball_components.dart'; +import 'package:pinball_components/src/components/layer_sensor/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +class _TestLayerSensor extends LayerSensor { + _TestLayerSensor({ + required LayerEntranceOrientation orientation, + required int insideZIndex, + required Layer insideLayer, + }) : super( + insideLayer: insideLayer, + insideZIndex: insideZIndex, + orientation: orientation, + ); + + @override + Shape get shape => PolygonShape()..setAsBoxXY(1, 1); +} + +class _MockBall extends Mock implements Ball {} + +class _MockBody extends Mock implements Body {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'LayerSensorBehavior', + () { + test('can be instantiated', () { + expect( + LayerFilteringBehavior(), + isA(), + ); + }); + + flameTester.test( + 'loads', + (game) async { + final behavior = LayerFilteringBehavior(); + final parent = _TestLayerSensor( + orientation: LayerEntranceOrientation.down, + insideZIndex: 1, + insideLayer: Layer.spaceshipEntranceRamp, + ); + + await parent.add(behavior); + await game.ensureAdd(parent); + + expect(game.contains(parent), isTrue); + }, + ); + + group('beginContact', () { + late Ball ball; + late Body body; + late int insideZIndex; + late Layer insideLayer; + + setUp(() { + ball = _MockBall(); + body = _MockBody(); + insideZIndex = 1; + insideLayer = Layer.spaceshipEntranceRamp; + + when(() => ball.body).thenReturn(body); + when(() => ball.layer).thenReturn(Layer.board); + }); + + flameTester.test( + 'changes ball layer and zIndex ' + 'when a ball enters and exits a downward oriented LayerSensor', + (game) async { + final parent = _TestLayerSensor( + orientation: LayerEntranceOrientation.down, + insideZIndex: 1, + insideLayer: insideLayer, + )..initialPosition = Vector2(0, 10); + final behavior = LayerFilteringBehavior(); + + await parent.add(behavior); + await game.ensureAdd(parent); + + when(() => body.linearVelocity).thenReturn(Vector2(0, -1)); + + behavior.beginContact(ball, _MockContact()); + verify(() => ball.layer = insideLayer).called(1); + verify(() => ball.zIndex = insideZIndex).called(1); + + when(() => ball.layer).thenReturn(insideLayer); + + behavior.beginContact(ball, _MockContact()); + verify(() => ball.layer = Layer.board); + verify(() => ball.zIndex = ZIndexes.ballOnBoard).called(1); + }); + + flameTester.test( + 'changes ball layer and zIndex ' + 'when a ball enters and exits an upward oriented LayerSensor', + (game) async { + final parent = _TestLayerSensor( + orientation: LayerEntranceOrientation.up, + insideZIndex: 1, + insideLayer: insideLayer, + )..initialPosition = Vector2(0, 10); + final behavior = LayerFilteringBehavior(); + + await parent.add(behavior); + await game.ensureAdd(parent); + + when(() => body.linearVelocity).thenReturn(Vector2(0, 1)); + + behavior.beginContact(ball, _MockContact()); + verify(() => ball.layer = insideLayer).called(1); + verify(() => ball.zIndex = 1).called(1); + + when(() => ball.layer).thenReturn(insideLayer); + + behavior.beginContact(ball, _MockContact()); + verify(() => ball.layer = Layer.board); + verify(() => ball.zIndex = ZIndexes.ballOnBoard).called(1); + }); + }); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/layer_sensor_test.dart b/packages/pinball_components/test/src/components/layer_sensor/layer_sensor_test.dart similarity index 59% rename from packages/pinball_components/test/src/components/layer_sensor_test.dart rename to packages/pinball_components/test/src/components/layer_sensor/layer_sensor_test.dart index cfd19bb0..dd32ad56 100644 --- a/packages/pinball_components/test/src/components/layer_sensor_test.dart +++ b/packages/pinball_components/test/src/components/layer_sensor/layer_sensor_test.dart @@ -2,16 +2,10 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/layer_sensor/behaviors/behaviors.dart'; -import '../../helpers/helpers.dart'; - -class _MockBall extends Mock implements Ball {} - -class _MockBody extends Mock implements Body {} - -class _MockContact extends Mock implements Contact {} +import '../../../helpers/helpers.dart'; class TestLayerSensor extends LayerSensor { TestLayerSensor({ @@ -112,68 +106,22 @@ void main() { ); }); }); - }); - - group('beginContact', () { - late Ball ball; - late Body body; - late int insideZIndex; - late Layer insideLayer; - - setUp(() { - ball = _MockBall(); - body = _MockBody(); - insideZIndex = 1; - insideLayer = Layer.spaceshipEntranceRamp; - - when(() => ball.body).thenReturn(body); - when(() => ball.layer).thenReturn(Layer.board); - }); - - flameTester.test( - 'changes ball layer and zIndex ' - 'when a ball enters and exits a downward oriented LayerSensor', - (game) async { - final sensor = TestLayerSensor( - orientation: LayerEntranceOrientation.down, - insideZIndex: insidePriority, - insideLayer: insideLayer, - )..initialPosition = Vector2(0, 10); - - when(() => body.linearVelocity).thenReturn(Vector2(0, -1)); - - sensor.beginContact(ball, _MockContact()); - verify(() => ball.layer = insideLayer).called(1); - verify(() => ball.zIndex = insideZIndex).called(1); - - when(() => ball.layer).thenReturn(insideLayer); - - sensor.beginContact(ball, _MockContact()); - verify(() => ball.layer = Layer.board); - verify(() => ball.zIndex = ZIndexes.ballOnBoard).called(1); - }); flameTester.test( - 'changes ball layer and zIndex ' - 'when a ball enters and exits an upward oriented LayerSensor', - (game) async { - final sensor = TestLayerSensor( - orientation: LayerEntranceOrientation.up, - insideZIndex: insidePriority, - insideLayer: insideLayer, - )..initialPosition = Vector2(0, 10); - - when(() => body.linearVelocity).thenReturn(Vector2(0, 1)); - - sensor.beginContact(ball, _MockContact()); - verify(() => ball.layer = insideLayer).called(1); - verify(() => ball.zIndex = insidePriority).called(1); - - when(() => ball.layer).thenReturn(insideLayer); + 'adds a LayerFilteringBehavior', + (game) async { + final layerSensor = TestLayerSensor( + orientation: LayerEntranceOrientation.down, + insideZIndex: insidePriority, + insideLayer: Layer.spaceshipEntranceRamp, + ); + await game.ensureAdd(layerSensor); - sensor.beginContact(ball, _MockContact()); - verify(() => ball.layer = Layer.board); - verify(() => ball.zIndex = ZIndexes.ballOnBoard).called(1); - }); + expect( + layerSensor.children.whereType().length, + equals(1), + ); + }, + ); }); } diff --git a/packages/pinball_components/test/src/components/plunger_test.dart b/packages/pinball_components/test/src/components/plunger_test.dart index abb42d68..fd759f8d 100644 --- a/packages/pinball_components/test/src/components/plunger_test.dart +++ b/packages/pinball_components/test/src/components/plunger_test.dart @@ -141,7 +141,11 @@ void main() { expect(plunger.body.linearVelocity.y, isPositive); - await tester.pump(const Duration(seconds: 2)); + // Call game update at 120 FPS, so that the plunger will act as if it + // was pulled for 2 seconds. + for (var i = 0.0; i < 2; i += 1 / 120) { + game.update(1 / 20); + } expect(plunger.body.linearVelocity.y, isZero); }, diff --git a/packages/pinball_components/test/src/components/score_component_test.dart b/packages/pinball_components/test/src/components/score_component_test.dart index 69688874..f2bd52e3 100644 --- a/packages/pinball_components/test/src/components/score_component_test.dart +++ b/packages/pinball_components/test/src/components/score_component_test.dart @@ -28,6 +28,7 @@ void main() { ScoreComponent( points: Points.oneMillion, position: Vector2.zero(), + effectController: EffectController(duration: 1), ), ); }, @@ -46,6 +47,7 @@ void main() { ScoreComponent( points: Points.oneMillion, position: Vector2.zero(), + effectController: EffectController(duration: 1), ), ); @@ -67,6 +69,7 @@ void main() { ScoreComponent( points: Points.oneMillion, position: Vector2.zero(), + effectController: EffectController(duration: 1), ), ); @@ -88,6 +91,7 @@ void main() { ScoreComponent( points: Points.fiveThousand, position: Vector2.zero(), + effectController: EffectController(duration: 1), ), ); @@ -113,6 +117,7 @@ void main() { ScoreComponent( points: Points.twentyThousand, position: Vector2.zero(), + effectController: EffectController(duration: 1), ), ); @@ -138,6 +143,7 @@ void main() { ScoreComponent( points: Points.twoHundredThousand, position: Vector2.zero(), + effectController: EffectController(duration: 1), ), ); @@ -163,6 +169,7 @@ void main() { ScoreComponent( points: Points.oneMillion, position: Vector2.zero(), + effectController: EffectController(duration: 1), ), ); diff --git a/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior_test.dart new file mode 100644 index 00000000..48a151a3 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior_test.dart @@ -0,0 +1,62 @@ +// 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_components/pinball_components.dart'; +import 'package:pinball_components/src/components/skill_shot/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockBall extends Mock implements Ball {} + +class _MockContact extends Mock implements Contact {} + +class _MockSkillShotCubit extends Mock implements SkillShotCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'SkillShotBallContactBehavior', + () { + test('can be instantiated', () { + expect( + SkillShotBallContactBehavior(), + isA(), + ); + }); + + flameTester.testGameWidget( + 'beginContact animates pin and calls onBallContacted ' + 'when contacts with a ball', + setUp: (game, tester) async { + await game.images.load(Assets.images.skillShot.pin.keyName); + final behavior = SkillShotBallContactBehavior(); + final bloc = _MockSkillShotCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.addAll([behavior, PinSpriteAnimationComponent()]); + await game.ensureAdd(skillShot); + + behavior.beginContact(_MockBall(), _MockContact()); + await tester.pump(); + + expect( + skillShot.firstChild()!.playing, + isTrue, + ); + verify(skillShot.bloc.onBallContacted).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_blinking_behavior_test.dart b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_blinking_behavior_test.dart new file mode 100644 index 00000000..e2d00f61 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_blinking_behavior_test.dart @@ -0,0 +1,125 @@ +// 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_components/pinball_components.dart'; +import 'package:pinball_components/src/components/skill_shot/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockSkillShotCubit extends Mock implements SkillShotCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'SkillShotBlinkingBehavior', + () { + flameTester.testGameWidget( + 'calls switched after 0.15 seconds when isBlinking and lit', + setUp: (game, tester) async { + final behavior = SkillShotBlinkingBehavior(); + final bloc = _MockSkillShotCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.add(behavior); + await game.ensureAdd(skillShot); + + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ); + await tester.pump(); + game.update(0.15); + + await streamController.close(); + verify(bloc.switched).called(1); + }, + ); + + flameTester.testGameWidget( + 'calls switched after 0.15 seconds when isBlinking and dimmed', + setUp: (game, tester) async { + final behavior = SkillShotBlinkingBehavior(); + final bloc = _MockSkillShotCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.add(behavior); + await game.ensureAdd(skillShot); + + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: true, + ), + ); + await tester.pump(); + game.update(0.15); + + await streamController.close(); + verify(bloc.switched).called(1); + }, + ); + + flameTester.testGameWidget( + 'calls onBlinkingFinished after all blinks complete', + setUp: (game, tester) async { + final behavior = SkillShotBlinkingBehavior(); + final bloc = _MockSkillShotCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.add(behavior); + await game.ensureAdd(skillShot); + + for (var i = 0; i <= 8; i++) { + if (i.isEven) { + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ); + } else { + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: true, + ), + ); + } + await tester.pump(); + game.update(0.15); + } + + await streamController.close(); + verify(bloc.onBlinkingFinished).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_cubit_test.dart b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_cubit_test.dart new file mode 100644 index 00000000..b165db99 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_cubit_test.dart @@ -0,0 +1,66 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group( + 'SkillShotCubit', + () { + blocTest( + 'onBallContacted emits lit and true', + build: SkillShotCubit.new, + act: (bloc) => bloc.onBallContacted(), + expect: () => [ + SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ], + ); + + blocTest( + 'switched emits lit when dimmed', + build: SkillShotCubit.new, + act: (bloc) => bloc.switched(), + expect: () => [ + isA().having( + (state) => state.spriteState, + 'spriteState', + SkillShotSpriteState.lit, + ) + ], + ); + + blocTest( + 'switched emits dimmed when lit', + build: SkillShotCubit.new, + seed: () => SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: false, + ), + act: (bloc) => bloc.switched(), + expect: () => [ + isA().having( + (state) => state.spriteState, + 'spriteState', + SkillShotSpriteState.dimmed, + ) + ], + ); + + blocTest( + 'onBlinkingFinished emits dimmed and false', + build: SkillShotCubit.new, + act: (bloc) => bloc.onBlinkingFinished(), + expect: () => [ + SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ), + ], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_state_test.dart b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_state_test.dart new file mode 100644 index 00000000..ee6e3e0d --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_state_test.dart @@ -0,0 +1,84 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group('SkillShotState', () { + test('supports value equality', () { + expect( + SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + equals( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ), + ); + }); + + group('constructor', () { + test('can be instantiated', () { + expect( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + isNotNull, + ); + }); + + test('initial is idle with mouth closed', () { + const initialState = SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ); + expect(SkillShotState.initial(), equals(initialState)); + }); + }); + + group('copyWith', () { + test( + 'copies correctly ' + 'when no argument specified', + () { + const chromeDinoState = SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ); + expect( + chromeDinoState.copyWith(), + equals(chromeDinoState), + ); + }, + ); + + test( + 'copies correctly ' + 'when all arguments specified', + () { + const chromeDinoState = SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ); + final otherSkillShotState = SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ); + expect(chromeDinoState, isNot(equals(otherSkillShotState))); + + expect( + chromeDinoState.copyWith( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ), + equals(otherSkillShotState), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/skill_shot_test.dart b/packages/pinball_components/test/src/components/skill_shot/skill_shot_test.dart new file mode 100644 index 00000000..dabacc69 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/skill_shot_test.dart @@ -0,0 +1,99 @@ +// 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/skill_shot/behaviors/behaviors.dart'; + +import '../../../helpers/helpers.dart'; + +class _MockSkillShotCubit extends Mock implements SkillShotCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.skillShot.decal.keyName, + Assets.images.skillShot.pin.keyName, + Assets.images.skillShot.lit.keyName, + Assets.images.skillShot.dimmed.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + group('SkillShot', () { + flameTester.test('loads correctly', (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + expect(game.contains(skillShot), 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 = _MockSkillShotCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SkillShotState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + final skillShot = SkillShot.test(bloc: bloc); + + await game.ensureAdd(skillShot); + game.remove(skillShot); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final skillShot = SkillShot( + children: [component], + ); + await game.ensureAdd(skillShot); + expect(skillShot.children, contains(component)); + }); + + flameTester.test('a SkillShotBallContactBehavior', (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + expect( + skillShot.children.whereType().single, + isNotNull, + ); + }); + + flameTester.test('a SkillShotBlinkingBehavior', (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + expect( + skillShot.children.whereType().single, + isNotNull, + ); + }); + }); + + flameTester.test( + 'pin stops animating after animation completes', + (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + + final pinSpriteAnimationComponent = + skillShot.firstChild()!; + + pinSpriteAnimationComponent.playing = true; + game.update( + pinSpriteAnimationComponent.animation!.totalDuration() + 0.1, + ); + + expect(pinSpriteAnimationComponent.playing, isFalse); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior_test.dart new file mode 100644 index 00000000..ea37550a --- /dev/null +++ b/packages/pinball_components/test/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior_test.dart @@ -0,0 +1,117 @@ +// 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_components/pinball_components.dart'; +import 'package:pinball_components/src/components/spaceship_ramp/behavior/behavior.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} + +class _MockBall extends Mock implements Ball {} + +class _MockBody extends Mock implements Body {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.android.ramp.boardOpening.keyName, + Assets.images.android.ramp.railingForeground.keyName, + Assets.images.android.ramp.railingBackground.keyName, + Assets.images.android.ramp.main.keyName, + Assets.images.android.ramp.arrow.inactive.keyName, + Assets.images.android.ramp.arrow.active1.keyName, + Assets.images.android.ramp.arrow.active2.keyName, + Assets.images.android.ramp.arrow.active3.keyName, + Assets.images.android.ramp.arrow.active4.keyName, + Assets.images.android.ramp.arrow.active5.keyName, + ]; + + final flameTester = FlameTester(() => TestGame(assets)); + + group( + 'RampBallAscendingContactBehavior', + () { + test('can be instantiated', () { + expect( + RampBallAscendingContactBehavior(), + isA(), + ); + }); + + group('beginContact', () { + late Ball ball; + late Body body; + + setUp(() { + ball = _MockBall(); + body = _MockBody(); + + when(() => ball.body).thenReturn(body); + }); + + flameTester.test( + "calls 'onAscendingBallEntered' when a ball enters into the ramp", + (game) async { + final behavior = RampBallAscendingContactBehavior(); + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SpaceshipRampState.initial(), + ); + + final rampSensor = RampScoringSensor.test(); + final spaceshipRamp = SpaceshipRamp.test( + bloc: bloc, + ); + + when(() => body.linearVelocity).thenReturn(Vector2(0, -1)); + + await spaceshipRamp.add(rampSensor); + await game.ensureAddAll([spaceshipRamp, ball]); + await rampSensor.add(behavior); + + behavior.beginContact(ball, _MockContact()); + + verify(bloc.onAscendingBallEntered).called(1); + }, + ); + + flameTester.test( + "doesn't call 'onAscendingBallEntered' when a ball goes out the ramp", + (game) async { + final behavior = RampBallAscendingContactBehavior(); + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SpaceshipRampState.initial(), + ); + + final rampSensor = RampScoringSensor.test(); + final spaceshipRamp = SpaceshipRamp.test( + bloc: bloc, + ); + + when(() => body.linearVelocity).thenReturn(Vector2(0, 1)); + + await spaceshipRamp.add(rampSensor); + await game.ensureAddAll([spaceshipRamp, ball]); + await rampSensor.add(behavior); + + behavior.beginContact(ball, _MockContact()); + + verifyNever(bloc.onAscendingBallEntered); + }, + ); + }); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit_test.dart new file mode 100644 index 00000000..b7e899fe --- /dev/null +++ b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit_test.dart @@ -0,0 +1,25 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group('SpaceshipRampCubit', () { + group('onAscendingBallEntered', () { + blocTest( + 'emits hits incremented and arrow goes to the next value', + build: SpaceshipRampCubit.new, + act: (bloc) => bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(), + expect: () => [ + SpaceshipRampState(hits: 1), + SpaceshipRampState(hits: 2), + SpaceshipRampState(hits: 3), + ], + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_state_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_state_test.dart new file mode 100644 index 00000000..536f4e8e --- /dev/null +++ b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_state_test.dart @@ -0,0 +1,78 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/src/components/components.dart'; + +void main() { + group('SpaceshipRampState', () { + test('supports value equality', () { + expect( + SpaceshipRampState(hits: 0), + equals( + SpaceshipRampState(hits: 0), + ), + ); + }); + + group('constructor', () { + test('can be instantiated', () { + expect( + SpaceshipRampState(hits: 0), + isNotNull, + ); + }); + }); + + test( + 'throws AssertionError ' + 'when hits is negative', + () { + expect( + () => SpaceshipRampState(hits: -1), + throwsAssertionError, + ); + }, + ); + + group('copyWith', () { + test( + 'throws AssertionError ' + 'when hits is decreased', + () { + const rampState = SpaceshipRampState(hits: 0); + expect( + () => rampState.copyWith(hits: rampState.hits - 1), + throwsAssertionError, + ); + }, + ); + + test( + 'copies correctly ' + 'when no argument specified', + () { + const rampState = SpaceshipRampState(hits: 0); + expect( + rampState.copyWith(), + equals(rampState), + ); + }, + ); + + test( + 'copies correctly ' + 'when all arguments specified', + () { + const rampState = SpaceshipRampState(hits: 0); + final otherRampState = SpaceshipRampState(hits: rampState.hits + 1); + expect(rampState, isNot(equals(otherRampState))); + + expect( + rampState.copyWith(hits: rampState.hits + 1), + equals(otherRampState), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/spaceship_ramp_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart similarity index 53% rename from packages/pinball_components/test/src/components/spaceship_ramp_test.dart rename to packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart index 0f2ce13a..b74cfb88 100644 --- a/packages/pinball_components/test/src/components/spaceship_ramp_test.dart +++ b/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart @@ -1,12 +1,16 @@ // 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_flame/pinball_flame.dart'; -import '../../helpers/helpers.dart'; +import '../../../helpers/helpers.dart'; + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -25,28 +29,35 @@ void main() { final flameTester = FlameTester(() => TestGame(assets)); group('SpaceshipRamp', () { - flameTester.test('loads correctly', (game) async { - final component = SpaceshipRamp(); - await game.ensureAdd(component); - expect(game.contains(component), isTrue); - }); + flameTester.test( + 'loads correctly', + (game) async { + final spaceshipRamp = SpaceshipRamp(); + await game.ensureAdd(spaceshipRamp); + expect(game.children, contains(spaceshipRamp)); + }, + ); group('renders correctly', () { - const goldenFilePath = 'golden/spaceship_ramp/'; + const goldenFilePath = '../golden/spaceship_ramp/'; final centerForSpaceshipRamp = Vector2(-13, -55); flameTester.testGameWidget( 'inactive sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.inactive, ); @@ -64,15 +75,21 @@ void main() { 'active1 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component.progress(); + ramp.bloc.onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active1, ); @@ -90,17 +107,23 @@ void main() { 'active2 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active2, ); @@ -118,18 +141,24 @@ void main() { 'active3 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active3, ); @@ -147,19 +176,25 @@ void main() { 'active4 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress() - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active4, ); @@ -177,20 +212,26 @@ void main() { 'active5 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress() - ..progress() - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active5, ); @@ -204,5 +245,34 @@ void main() { }, ); }); + + flameTester.test('closes bloc when removed', (game) async { + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SpaceshipRampState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + + final ramp = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ramp); + game.remove(ramp); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final ramp = SpaceshipRamp(children: [component]); + await game.ensureAdd(ramp); + expect(ramp.children, contains(component)); + }); + }); }); } diff --git a/packages/pinball_components/test/src/extensions/score_test.dart b/packages/pinball_components/test/src/extensions/score_test.dart new file mode 100644 index 00000000..d8546ea1 --- /dev/null +++ b/packages/pinball_components/test/src/extensions/score_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group('ScoreX', () { + test('formatScore correctly formats int', () { + expect(1000000.formatScore(), '1,000,000'); + }); + }); +} diff --git a/packages/pinball_flame/lib/pinball_flame.dart b/packages/pinball_flame/lib/pinball_flame.dart index 66d34b14..6f8a40f7 100644 --- a/packages/pinball_flame/lib/pinball_flame.dart +++ b/packages/pinball_flame/lib/pinball_flame.dart @@ -1,8 +1,9 @@ library pinball_flame; +export 'src/canvas/canvas.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/pinball_forge2d_game.dart'; export 'src/sprite_animation.dart'; -export 'src/z_canvas_component.dart'; diff --git a/packages/pinball_flame/lib/src/canvas/canvas.dart b/packages/pinball_flame/lib/src/canvas/canvas.dart new file mode 100644 index 00000000..9c0c7a70 --- /dev/null +++ b/packages/pinball_flame/lib/src/canvas/canvas.dart @@ -0,0 +1,2 @@ +export 'canvas_component.dart'; +export 'z_canvas_component.dart'; diff --git a/packages/pinball_flame/lib/src/canvas/canvas_component.dart b/packages/pinball_flame/lib/src/canvas/canvas_component.dart new file mode 100644 index 00000000..ca6e64d0 --- /dev/null +++ b/packages/pinball_flame/lib/src/canvas/canvas_component.dart @@ -0,0 +1,47 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:pinball_flame/src/canvas/canvas_wrapper.dart'; + +/// Called right before [Canvas.drawImageRect] is called. +/// +/// This is useful since [Sprite.render] uses [Canvas.drawImageRect] to draw +/// the [Sprite]. +typedef PaintFunction = void Function(Paint); + +/// {@template canvas_component} +/// Allows listening before the rendering of [Sprite]s. +/// +/// The existance of this class is to hack around the fact that Flame doesn't +/// provide a global way to modify the default [Paint] before rendering a +/// [Sprite]. +/// {@endtemplate} +class CanvasComponent extends Component { + /// {@macro canvas_component} + CanvasComponent({ + PaintFunction? onSpritePainted, + Iterable? children, + }) : _canvas = _Canvas(onSpritePainted: onSpritePainted), + super(children: children); + + final _Canvas _canvas; + + @override + void renderTree(Canvas canvas) { + _canvas.canvas = canvas; + super.renderTree(_canvas); + } +} + +class _Canvas extends CanvasWrapper { + _Canvas({PaintFunction? onSpritePainted}) + : _onSpritePainted = onSpritePainted; + + final PaintFunction? _onSpritePainted; + + @override + void drawImageRect(Image image, Rect src, Rect dst, Paint paint) { + _onSpritePainted?.call(paint); + super.drawImageRect(image, src, dst, paint); + } +} diff --git a/packages/pinball_flame/lib/src/z_canvas_component.dart b/packages/pinball_flame/lib/src/canvas/canvas_wrapper.dart similarity index 65% rename from packages/pinball_flame/lib/src/z_canvas_component.dart rename to packages/pinball_flame/lib/src/canvas/canvas_wrapper.dart index 911c3e93..883527d2 100644 --- a/packages/pinball_flame/lib/src/z_canvas_component.dart +++ b/packages/pinball_flame/lib/src/canvas/canvas_wrapper.dart @@ -1,85 +1,11 @@ +// ignore_for_file: public_member_api_docs + import 'dart:typed_data'; import 'dart:ui'; -import 'package:flame/components.dart'; - -/// {@template z_canvas_component} -/// Draws [ZIndex] components after the all non-[ZIndex] components have been -/// drawn. -/// {@endtemplate} -class ZCanvasComponent extends Component { - /// {@macro z_canvas_component} - ZCanvasComponent({ - Iterable? children, - }) : _zCanvas = ZCanvas(), - super(children: children); - - final ZCanvas _zCanvas; - - @override - void renderTree(Canvas canvas) { - _zCanvas.canvas = canvas; - super.renderTree(_zCanvas); - _zCanvas.render(); - } -} - -/// Apply to any [Component] that will be rendered according to a -/// [ZIndex.zIndex]. -/// -/// [ZIndex] components must be descendants of a [ZCanvasComponent]. -/// -/// {@macro z_canvas.render} -mixin ZIndex on Component { - /// The z-index of this component. - /// - /// The higher the value, the later the component will be drawn. Hence, - /// rendering in front of [Component]s with lower [zIndex] values. - int zIndex = 0; - - @override - void renderTree( - Canvas canvas, - ) { - if (canvas is ZCanvas) { - canvas.buffer(this); - } else { - super.renderTree(canvas); - } - } -} - -/// The [ZCanvas] allows to postpone the rendering of [ZIndex] components. -/// -/// You should not use this class directly. -class ZCanvas implements Canvas { - /// The [Canvas] to render to. - /// - /// This is set by [ZCanvasComponent] when rendering. +class CanvasWrapper implements Canvas { late Canvas canvas; - final List _zBuffer = []; - - /// Postpones the rendering of [ZIndex] component and its children. - void buffer(ZIndex component) => _zBuffer.add(component); - - /// Renders all [ZIndex] components and their children. - /// - /// {@template z_canvas.render} - /// The rendering order is defined by the parent [ZIndex]. The children of - /// the same parent are rendered in the order they were added. - /// - /// If two [Component]s ever overlap each other, and have the same - /// [ZIndex.zIndex], there is no guarantee that the first one will be rendered - /// before the second one. - /// {@endtemplate} - void render() => _zBuffer - ..sort((a, b) => a.zIndex.compareTo(b.zIndex)) - ..whereType().forEach(_render) - ..clear(); - - void _render(Component component) => component.renderTree(canvas); - @override void clipPath(Path path, {bool doAntiAlias = true}) => canvas.clipPath(path, doAntiAlias: doAntiAlias); diff --git a/packages/pinball_flame/lib/src/canvas/z_canvas_component.dart b/packages/pinball_flame/lib/src/canvas/z_canvas_component.dart new file mode 100644 index 00000000..e097f359 --- /dev/null +++ b/packages/pinball_flame/lib/src/canvas/z_canvas_component.dart @@ -0,0 +1,77 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:pinball_flame/src/canvas/canvas_wrapper.dart'; + +/// {@template z_canvas_component} +/// Draws [ZIndex] components after the all non-[ZIndex] components have been +/// drawn. +/// {@endtemplate} +class ZCanvasComponent extends Component { + /// {@macro z_canvas_component} + ZCanvasComponent({ + Iterable? children, + }) : _zCanvas = _ZCanvas(), + super(children: children); + + final _ZCanvas _zCanvas; + + @override + void renderTree(Canvas canvas) { + _zCanvas.canvas = canvas; + super.renderTree(_zCanvas); + _zCanvas.render(); + } +} + +/// Apply to any [Component] that will be rendered according to a +/// [ZIndex.zIndex]. +/// +/// [ZIndex] components must be descendants of a [ZCanvasComponent]. +/// +/// {@macro z_canvas.render} +mixin ZIndex on Component { + /// The z-index of this component. + /// + /// The higher the value, the later the component will be drawn. Hence, + /// rendering in front of [Component]s with lower [zIndex] values. + int zIndex = 0; + + @override + void renderTree( + Canvas canvas, + ) { + if (canvas is _ZCanvas) { + canvas.buffer(this); + } else { + super.renderTree(canvas); + } + } +} + +/// The [_ZCanvas] allows to postpone the rendering of [ZIndex] components. +/// +/// You should not use this class directly. +class _ZCanvas extends CanvasWrapper { + final List _zBuffer = []; + + /// Postpones the rendering of [ZIndex] component and its children. + void buffer(ZIndex component) => _zBuffer.add(component); + + /// Renders all [ZIndex] components and their children. + /// + /// {@template z_canvas.render} + /// The rendering order is defined by the parent [ZIndex]. The children of + /// the same parent are rendered in the order they were added. + /// + /// If two [Component]s ever overlap each other, and have the same + /// [ZIndex.zIndex], there is no guarantee that the first one will be rendered + /// before the second one. + /// {@endtemplate} + void render() => _zBuffer + ..sort((a, b) => a.zIndex.compareTo(b.zIndex)) + ..whereType().forEach(_render) + ..clear(); + + void _render(Component component) => component.renderTree(canvas); +} diff --git a/packages/pinball_flame/lib/src/pinball_forge2d_game.dart b/packages/pinball_flame/lib/src/pinball_forge2d_game.dart new file mode 100644 index 00000000..0013dd26 --- /dev/null +++ b/packages/pinball_flame/lib/src/pinball_forge2d_game.dart @@ -0,0 +1,54 @@ +import 'dart:math'; + +import 'package:flame/game.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_forge2d/world_contact_listener.dart'; + +// NOTE(wolfen): This should be removed when https://github.com/flame-engine/flame/pull/1597 is solved. +/// {@template pinball_forge2d_game} +/// A [Game] that uses the Forge2D physics engine. +/// {@endtemplate} +class PinballForge2DGame extends FlameGame implements Forge2DGame { + /// {@macro pinball_forge2d_game} + PinballForge2DGame({ + required Vector2 gravity, + }) : world = World(gravity), + super(camera: Camera()) { + camera.zoom = Forge2DGame.defaultZoom; + world.setContactListener(WorldContactListener()); + } + + @override + final World world; + + @override + void update(double dt) { + super.update(dt); + world.stepDt(clampDt(dt)); + } + + @override + Vector2 screenToFlameWorld(Vector2 position) { + throw UnimplementedError(); + } + + @override + Vector2 screenToWorld(Vector2 position) { + throw UnimplementedError(); + } + + @override + Vector2 worldToScreen(Vector2 position) { + throw UnimplementedError(); + } + + /// Clamp the [dt] in such a way that it would never exceed the minimal of + /// 1/60th of a second. + /// + /// Note: this is a static method because composing this class as a generic + /// on `BodyComponent` and mixins for that class will crash the Dart analyzer + /// server. + static double clampDt(double dt) { + return min(dt, 1 / 60); + } +} diff --git a/packages/pinball_flame/test/src/canvas/canvas_component_test.dart b/packages/pinball_flame/test/src/canvas/canvas_component_test.dart new file mode 100644 index 00000000..7bf7fd88 --- /dev/null +++ b/packages/pinball_flame/test/src/canvas/canvas_component_test.dart @@ -0,0 +1,144 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_flame/src/canvas/canvas_component.dart'; + +class _TestSpriteComponent extends SpriteComponent {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CanvasComponent', () { + final flameTester = FlameTester(FlameGame.new); + + test('can be instantiated', () { + expect( + CanvasComponent(), + isA(), + ); + }); + + flameTester.test('loads correctly', (game) async { + final component = CanvasComponent(); + await game.ensureAdd(component); + expect(game.contains(component), isTrue); + }); + + flameTester.test( + 'adds children', + (game) async { + final component = Component(); + final canvas = CanvasComponent( + onSpritePainted: (paint) => paint.filterQuality = FilterQuality.high, + children: [component], + ); + + await game.ensureAdd(canvas); + + expect( + canvas.children.contains(component), + isTrue, + ); + }, + ); + + flameTester.testGameWidget( + 'calls onSpritePainted when paiting a sprite', + setUp: (game, tester) async { + final spriteComponent = _TestSpriteComponent(); + + final completer = Completer(); + decodeImageFromList( + Uint8List.fromList(_image), + completer.complete, + ); + spriteComponent.sprite = Sprite(await completer.future); + + var calls = 0; + final canvas = CanvasComponent( + onSpritePainted: (paint) => calls++, + children: [spriteComponent], + ); + + await game.ensureAdd(canvas); + await tester.pump(); + + expect(calls, equals(1)); + }, + ); + }); +} + +const List _image = [ + 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/packages/pinball_flame/test/src/canvas/canvas_wrapper_test.dart b/packages/pinball_flame/test/src/canvas/canvas_wrapper_test.dart new file mode 100644 index 00000000..58da1ecd --- /dev/null +++ b/packages/pinball_flame/test/src/canvas/canvas_wrapper_test.dart @@ -0,0 +1,353 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/material.dart' hide Image; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_flame/src/canvas/canvas_wrapper.dart'; + +class _MockCanvas extends Mock implements Canvas {} + +class _MockImage extends Mock implements Image {} + +class _MockPicture extends Mock implements Picture {} + +class _MockParagraph extends Mock implements Paragraph {} + +class _MockVertices extends Mock implements Vertices {} + +void main() { + group('CanvasWrapper', () { + group('CanvasWrapper', () { + late Canvas canvas; + late Path path; + late RRect rRect; + late Rect rect; + late Paint paint; + late Image atlas; + late BlendMode blendMode; + late Color color; + late Offset offset; + late Float64List float64list; + late Float32List float32list; + late Int32List int32list; + late Picture picture; + late Paragraph paragraph; + late Vertices vertices; + + setUp(() { + canvas = _MockCanvas(); + path = Path(); + rRect = RRect.zero; + rect = Rect.zero; + paint = Paint(); + atlas = _MockImage(); + blendMode = BlendMode.clear; + color = Colors.black; + offset = Offset.zero; + float64list = Float64List(1); + float32list = Float32List(1); + int32list = Int32List(1); + picture = _MockPicture(); + paragraph = _MockParagraph(); + vertices = _MockVertices(); + }); + + test("clipPath calls Canvas's clipPath", () { + CanvasWrapper() + ..canvas = canvas + ..clipPath(path, doAntiAlias: false); + verify( + () => canvas.clipPath(path, doAntiAlias: false), + ).called(1); + }); + + test("clipRRect calls Canvas's clipRRect", () { + CanvasWrapper() + ..canvas = canvas + ..clipRRect(rRect, doAntiAlias: false); + verify( + () => canvas.clipRRect(rRect, doAntiAlias: false), + ).called(1); + }); + + test("clipRect calls Canvas's clipRect", () { + CanvasWrapper() + ..canvas = canvas + ..clipRect(rect, doAntiAlias: false); + verify( + () => canvas.clipRect(rect, doAntiAlias: false), + ).called(1); + }); + + test("drawArc calls Canvas's drawArc", () { + CanvasWrapper() + ..canvas = canvas + ..drawArc(rect, 0, 1, false, paint); + verify( + () => canvas.drawArc(rect, 0, 1, false, paint), + ).called(1); + }); + + test("drawAtlas calls Canvas's drawAtlas", () { + CanvasWrapper() + ..canvas = canvas + ..drawAtlas(atlas, [], [], [], blendMode, rect, paint); + verify( + () => canvas.drawAtlas(atlas, [], [], [], blendMode, rect, paint), + ).called(1); + }); + + test("drawCircle calls Canvas's drawCircle", () { + CanvasWrapper() + ..canvas = canvas + ..drawCircle(offset, 0, paint); + verify( + () => canvas.drawCircle(offset, 0, paint), + ).called(1); + }); + + test("drawColor calls Canvas's drawColor", () { + CanvasWrapper() + ..canvas = canvas + ..drawColor(color, blendMode); + verify( + () => canvas.drawColor(color, blendMode), + ).called(1); + }); + + test("drawDRRect calls Canvas's drawDRRect", () { + CanvasWrapper() + ..canvas = canvas + ..drawDRRect(rRect, rRect, paint); + verify( + () => canvas.drawDRRect(rRect, rRect, paint), + ).called(1); + }); + + test("drawImage calls Canvas's drawImage", () { + CanvasWrapper() + ..canvas = canvas + ..drawImage(atlas, offset, paint); + verify( + () => canvas.drawImage(atlas, offset, paint), + ).called(1); + }); + + test("drawImageNine calls Canvas's drawImageNine", () { + CanvasWrapper() + ..canvas = canvas + ..drawImageNine(atlas, rect, rect, paint); + verify( + () => canvas.drawImageNine(atlas, rect, rect, paint), + ).called(1); + }); + + test("drawImageRect calls Canvas's drawImageRect", () { + CanvasWrapper() + ..canvas = canvas + ..drawImageRect(atlas, rect, rect, paint); + verify( + () => canvas.drawImageRect(atlas, rect, rect, paint), + ).called(1); + }); + + test("drawLine calls Canvas's drawLine", () { + CanvasWrapper() + ..canvas = canvas + ..drawLine(offset, offset, paint); + verify( + () => canvas.drawLine(offset, offset, paint), + ).called(1); + }); + + test("drawOval calls Canvas's drawOval", () { + CanvasWrapper() + ..canvas = canvas + ..drawOval(rect, paint); + verify( + () => canvas.drawOval(rect, paint), + ).called(1); + }); + + test("drawPaint calls Canvas's drawPaint", () { + CanvasWrapper() + ..canvas = canvas + ..drawPaint(paint); + verify( + () => canvas.drawPaint(paint), + ).called(1); + }); + + test("drawParagraph calls Canvas's drawParagraph", () { + CanvasWrapper() + ..canvas = canvas + ..drawParagraph(paragraph, offset); + verify( + () => canvas.drawParagraph(paragraph, offset), + ).called(1); + }); + + test("drawPath calls Canvas's drawPath", () { + CanvasWrapper() + ..canvas = canvas + ..drawPath(path, paint); + verify( + () => canvas.drawPath(path, paint), + ).called(1); + }); + + test("drawPicture calls Canvas's drawPicture", () { + CanvasWrapper() + ..canvas = canvas + ..drawPicture(picture); + verify( + () => canvas.drawPicture(picture), + ).called(1); + }); + + test("drawPoints calls Canvas's drawPoints", () { + CanvasWrapper() + ..canvas = canvas + ..drawPoints(PointMode.points, [offset], paint); + verify( + () => canvas.drawPoints(PointMode.points, [offset], paint), + ).called(1); + }); + + test("drawRRect calls Canvas's drawRRect", () { + CanvasWrapper() + ..canvas = canvas + ..drawRRect(rRect, paint); + verify( + () => canvas.drawRRect(rRect, paint), + ).called(1); + }); + + test("drawRawAtlas calls Canvas's drawRawAtlas", () { + CanvasWrapper() + ..canvas = canvas + ..drawRawAtlas( + atlas, + float32list, + float32list, + int32list, + BlendMode.clear, + rect, + paint, + ); + verify( + () => canvas.drawRawAtlas( + atlas, + float32list, + float32list, + int32list, + BlendMode.clear, + rect, + paint, + ), + ).called(1); + }); + + test("drawRawPoints calls Canvas's drawRawPoints", () { + CanvasWrapper() + ..canvas = canvas + ..drawRawPoints(PointMode.points, float32list, paint); + verify( + () => canvas.drawRawPoints(PointMode.points, float32list, paint), + ).called(1); + }); + + test("drawRect calls Canvas's drawRect", () { + CanvasWrapper() + ..canvas = canvas + ..drawRect(rect, paint); + verify( + () => canvas.drawRect(rect, paint), + ).called(1); + }); + + test("drawShadow calls Canvas's drawShadow", () { + CanvasWrapper() + ..canvas = canvas + ..drawShadow(path, color, 0, false); + verify( + () => canvas.drawShadow(path, color, 0, false), + ).called(1); + }); + + test("drawVertices calls Canvas's drawVertices", () { + CanvasWrapper() + ..canvas = canvas + ..drawVertices(vertices, blendMode, paint); + verify( + () => canvas.drawVertices(vertices, blendMode, paint), + ).called(1); + }); + + test("getSaveCount calls Canvas's getSaveCount", () { + final canvasWrapper = CanvasWrapper()..canvas = canvas; + when(() => canvas.getSaveCount()).thenReturn(1); + canvasWrapper.getSaveCount(); + verify(() => canvas.getSaveCount()).called(1); + expect(canvasWrapper.getSaveCount(), 1); + }); + + test("restore calls Canvas's restore", () { + CanvasWrapper() + ..canvas = canvas + ..restore(); + verify(() => canvas.restore()).called(1); + }); + + test("rotate calls Canvas's rotate", () { + CanvasWrapper() + ..canvas = canvas + ..rotate(0); + verify(() => canvas.rotate(0)).called(1); + }); + + test("save calls Canvas's save", () { + CanvasWrapper() + ..canvas = canvas + ..save(); + verify(() => canvas.save()).called(1); + }); + + test("saveLayer calls Canvas's saveLayer", () { + CanvasWrapper() + ..canvas = canvas + ..saveLayer(rect, paint); + verify(() => canvas.saveLayer(rect, paint)).called(1); + }); + + test("scale calls Canvas's scale", () { + CanvasWrapper() + ..canvas = canvas + ..scale(0, 0); + verify(() => canvas.scale(0, 0)).called(1); + }); + + test("skew calls Canvas's skew", () { + CanvasWrapper() + ..canvas = canvas + ..skew(0, 0); + verify(() => canvas.skew(0, 0)).called(1); + }); + + test("transform calls Canvas's transform", () { + CanvasWrapper() + ..canvas = canvas + ..transform(float64list); + verify(() => canvas.transform(float64list)).called(1); + }); + + test("translate calls Canvas's translate", () { + CanvasWrapper() + ..canvas = canvas + ..translate(0, 0); + verify(() => canvas.translate(0, 0)).called(1); + }); + }); + }); +} diff --git a/packages/pinball_flame/test/src/canvas/z_canvas_component_test.dart b/packages/pinball_flame/test/src/canvas/z_canvas_component_test.dart new file mode 100644 index 00000000..67c45ec7 --- /dev/null +++ b/packages/pinball_flame/test/src/canvas/z_canvas_component_test.dart @@ -0,0 +1,80 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart' hide Image; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class _TestCircleComponent extends CircleComponent with ZIndex { + _TestCircleComponent(Color color) + : super( + paint: Paint()..color = color, + radius: 10, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ZCanvasComponent', () { + final flameTester = FlameTester(FlameGame.new); + const goldensFilePath = '../goldens/rendering/'; + + test('can be instantiated', () { + expect( + ZCanvasComponent(), + isA(), + ); + }); + + flameTester.test('loads correctly', (game) async { + final component = ZCanvasComponent(); + await game.ensureAdd(component); + expect(game.contains(component), isTrue); + }); + + flameTester.testGameWidget( + 'red circle renders behind blue circle', + setUp: (game, tester) async { + final canvas = ZCanvasComponent( + children: [ + _TestCircleComponent(Colors.blue)..zIndex = 1, + _TestCircleComponent(Colors.red)..zIndex = 0, + ], + ); + await game.ensureAdd(canvas); + + game.camera.followVector2(Vector2.zero()); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('${goldensFilePath}red_blue.png'), + ); + }, + ); + + flameTester.testGameWidget( + 'blue circle renders behind red circle', + setUp: (game, tester) async { + final canvas = ZCanvasComponent( + children: [ + _TestCircleComponent(Colors.blue)..zIndex = 0, + _TestCircleComponent(Colors.red)..zIndex = 1 + ], + ); + await game.ensureAdd(canvas); + + game.camera.followVector2(Vector2.zero()); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('${goldensFilePath}blue_red.png'), + ); + }, + ); + }); +} diff --git a/packages/pinball_flame/test/src/goldens/rendering/blue_red.png b/packages/pinball_flame/test/src/goldens/rendering/blue_red.png new file mode 100644 index 00000000..4ca86375 Binary files /dev/null and b/packages/pinball_flame/test/src/goldens/rendering/blue_red.png differ diff --git a/packages/pinball_flame/test/src/goldens/rendering/red_blue.png b/packages/pinball_flame/test/src/goldens/rendering/red_blue.png new file mode 100644 index 00000000..a657024f Binary files /dev/null and b/packages/pinball_flame/test/src/goldens/rendering/red_blue.png differ diff --git a/packages/pinball_flame/test/src/pinball_forge2d_game_test.dart b/packages/pinball_flame/test/src/pinball_forge2d_game_test.dart new file mode 100644 index 00000000..b8dc9fcc --- /dev/null +++ b/packages/pinball_flame/test/src/pinball_forge2d_game_test.dart @@ -0,0 +1,63 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/extensions.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +void main() { + final flameTester = FlameTester( + () => PinballForge2DGame(gravity: Vector2.zero()), + ); + + group('PinballForge2DGame', () { + test('can instantiate', () { + expect( + () => PinballForge2DGame(gravity: Vector2.zero()), + returnsNormally, + ); + }); + + flameTester.test( + 'screenToFlameWorld throws UnimpelementedError', + (game) async { + expect( + () => game.screenToFlameWorld(Vector2.zero()), + throwsUnimplementedError, + ); + }, + ); + + flameTester.test( + 'screenToWorld throws UnimpelementedError', + (game) async { + expect( + () => game.screenToWorld(Vector2.zero()), + throwsUnimplementedError, + ); + }, + ); + + flameTester.test( + 'worldToScreen throws UnimpelementedError', + (game) async { + expect( + () => game.worldToScreen(Vector2.zero()), + throwsUnimplementedError, + ); + }, + ); + + group('clampDt', () { + test('returns dt', () { + const dt = 0.0001; + expect(PinballForge2DGame.clampDt(dt), equals(dt)); + }); + + test('returns result of 1/60 as dt is to high', () { + const dt = 1.0; + expect(PinballForge2DGame.clampDt(dt), equals(1 / 60)); + }); + }); + }); +} diff --git a/packages/pinball_flame/test/src/rendering/z_canvas_component_test.dart b/packages/pinball_flame/test/src/rendering/z_canvas_component_test.dart deleted file mode 100644 index b6007bc5..00000000 --- a/packages/pinball_flame/test/src/rendering/z_canvas_component_test.dart +++ /dev/null @@ -1,385 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'dart:typed_data'; -import 'dart:ui'; - -import 'package:flame/components.dart'; -import 'package:flame/game.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/material.dart' hide Image; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -class _TestCircleComponent extends CircleComponent with ZIndex { - _TestCircleComponent(Color color) - : super( - paint: Paint()..color = color, - radius: 10, - ); -} - -class _MockCanvas extends Mock implements Canvas {} - -class _MockImage extends Mock implements Image {} - -class _MockPicture extends Mock implements Picture {} - -class _MockParagraph extends Mock implements Paragraph {} - -class _MockVertices extends Mock implements Vertices {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(FlameGame.new); - const goldenPrefix = 'golden/rendering/'; - - group('ZCanvasComponent', () { - flameTester.test('loads correctly', (game) async { - final component = ZCanvasComponent(); - await game.ensureAdd(component); - expect(game.contains(component), isTrue); - }); - - flameTester.testGameWidget( - 'red circle renders behind blue circle', - setUp: (game, tester) async { - final canvas = ZCanvasComponent( - children: [ - _TestCircleComponent(Colors.blue)..zIndex = 1, - _TestCircleComponent(Colors.red)..zIndex = 0, - ], - ); - await game.ensureAdd(canvas); - - game.camera.followVector2(Vector2.zero()); - }, - verify: (game, tester) async { - await expectLater( - find.byGame(), - matchesGoldenFile('${goldenPrefix}red_blue.png'), - ); - }, - ); - - flameTester.testGameWidget( - 'blue circle renders behind red circle', - setUp: (game, tester) async { - final canvas = ZCanvasComponent( - children: [ - _TestCircleComponent(Colors.blue)..zIndex = 0, - _TestCircleComponent(Colors.red)..zIndex = 1 - ], - ); - await game.ensureAdd(canvas); - - game.camera.followVector2(Vector2.zero()); - }, - verify: (game, tester) async { - await expectLater( - find.byGame(), - matchesGoldenFile('${goldenPrefix}blue_red.png'), - ); - }, - ); - }); - - group('ZCanvas', () { - late Canvas canvas; - late Path path; - late RRect rRect; - late Rect rect; - late Paint paint; - late Image atlas; - late BlendMode blendMode; - late Color color; - late Offset offset; - late Float64List float64list; - late Float32List float32list; - late Int32List int32list; - late Picture picture; - late Paragraph paragraph; - late Vertices vertices; - - setUp(() { - canvas = _MockCanvas(); - path = Path(); - rRect = RRect.zero; - rect = Rect.zero; - paint = Paint(); - atlas = _MockImage(); - blendMode = BlendMode.clear; - color = Colors.black; - offset = Offset.zero; - float64list = Float64List(1); - float32list = Float32List(1); - int32list = Int32List(1); - picture = _MockPicture(); - paragraph = _MockParagraph(); - vertices = _MockVertices(); - }); - - test("clipPath calls Canvas's clipPath", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.clipPath(path, doAntiAlias: false); - verify( - () => canvas.clipPath(path, doAntiAlias: false), - ).called(1); - }); - - test("clipRRect calls Canvas's clipRRect", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.clipRRect(rRect, doAntiAlias: false); - verify( - () => canvas.clipRRect(rRect, doAntiAlias: false), - ).called(1); - }); - - test("clipRect calls Canvas's clipRect", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.clipRect(rect, doAntiAlias: false); - verify( - () => canvas.clipRect(rect, doAntiAlias: false), - ).called(1); - }); - - test("drawArc calls Canvas's drawArc", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawArc(rect, 0, 1, false, paint); - verify( - () => canvas.drawArc(rect, 0, 1, false, paint), - ).called(1); - }); - - test("drawAtlas calls Canvas's drawAtlas", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawAtlas(atlas, [], [], [], blendMode, rect, paint); - verify( - () => canvas.drawAtlas(atlas, [], [], [], blendMode, rect, paint), - ).called(1); - }); - - test("drawCircle calls Canvas's drawCircle", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawCircle(offset, 0, paint); - verify( - () => canvas.drawCircle(offset, 0, paint), - ).called(1); - }); - - test("drawColor calls Canvas's drawColor", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawColor(color, blendMode); - verify( - () => canvas.drawColor(color, blendMode), - ).called(1); - }); - - test("drawDRRect calls Canvas's drawDRRect", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawDRRect(rRect, rRect, paint); - verify( - () => canvas.drawDRRect(rRect, rRect, paint), - ).called(1); - }); - - test("drawImage calls Canvas's drawImage", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawImage(atlas, offset, paint); - verify( - () => canvas.drawImage(atlas, offset, paint), - ).called(1); - }); - - test("drawImageNine calls Canvas's drawImageNine", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawImageNine(atlas, rect, rect, paint); - verify( - () => canvas.drawImageNine(atlas, rect, rect, paint), - ).called(1); - }); - - test("drawImageRect calls Canvas's drawImageRect", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawImageRect(atlas, rect, rect, paint); - verify( - () => canvas.drawImageRect(atlas, rect, rect, paint), - ).called(1); - }); - - test("drawLine calls Canvas's drawLine", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawLine(offset, offset, paint); - verify( - () => canvas.drawLine(offset, offset, paint), - ).called(1); - }); - - test("drawOval calls Canvas's drawOval", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawOval(rect, paint); - verify( - () => canvas.drawOval(rect, paint), - ).called(1); - }); - - test("drawPaint calls Canvas's drawPaint", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawPaint(paint); - verify( - () => canvas.drawPaint(paint), - ).called(1); - }); - - test("drawParagraph calls Canvas's drawParagraph", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawParagraph(paragraph, offset); - verify( - () => canvas.drawParagraph(paragraph, offset), - ).called(1); - }); - - test("drawPath calls Canvas's drawPath", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawPath(path, paint); - verify( - () => canvas.drawPath(path, paint), - ).called(1); - }); - - test("drawPicture calls Canvas's drawPicture", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawPicture(picture); - verify( - () => canvas.drawPicture(picture), - ).called(1); - }); - - test("drawPoints calls Canvas's drawPoints", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawPoints(PointMode.points, [offset], paint); - verify( - () => canvas.drawPoints(PointMode.points, [offset], paint), - ).called(1); - }); - - test("drawRRect calls Canvas's drawRRect", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawRRect(rRect, paint); - verify( - () => canvas.drawRRect(rRect, paint), - ).called(1); - }); - - test("drawRawAtlas calls Canvas's drawRawAtlas", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawRawAtlas( - atlas, - float32list, - float32list, - int32list, - BlendMode.clear, - rect, - paint, - ); - verify( - () => canvas.drawRawAtlas( - atlas, - float32list, - float32list, - int32list, - BlendMode.clear, - rect, - paint, - ), - ).called(1); - }); - - test("drawRawPoints calls Canvas's drawRawPoints", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawRawPoints(PointMode.points, float32list, paint); - verify( - () => canvas.drawRawPoints(PointMode.points, float32list, paint), - ).called(1); - }); - - test("drawRect calls Canvas's drawRect", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawRect(rect, paint); - verify( - () => canvas.drawRect(rect, paint), - ).called(1); - }); - - test("drawShadow calls Canvas's drawShadow", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawShadow(path, color, 0, false); - verify( - () => canvas.drawShadow(path, color, 0, false), - ).called(1); - }); - - test("drawVertices calls Canvas's drawVertices", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.drawVertices(vertices, blendMode, paint); - verify( - () => canvas.drawVertices(vertices, blendMode, paint), - ).called(1); - }); - - test("getSaveCount calls Canvas's getSaveCount", () { - final zcanvas = ZCanvas()..canvas = canvas; - when(() => canvas.getSaveCount()).thenReturn(1); - zcanvas.getSaveCount(); - verify(() => canvas.getSaveCount()).called(1); - }); - - test("restore calls Canvas's restore", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.restore(); - verify(() => canvas.restore()).called(1); - }); - - test("rotate calls Canvas's rotate", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.rotate(0); - verify(() => canvas.rotate(0)).called(1); - }); - - test("save calls Canvas's save", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.save(); - verify(() => canvas.save()).called(1); - }); - - test("saveLayer calls Canvas's saveLayer", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.saveLayer(rect, paint); - verify(() => canvas.saveLayer(rect, paint)).called(1); - }); - - test("scale calls Canvas's scale", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.scale(0, 0); - verify(() => canvas.scale(0, 0)).called(1); - }); - - test("skew calls Canvas's skew", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.skew(0, 0); - verify(() => canvas.skew(0, 0)).called(1); - }); - - test("transform calls Canvas's transform", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.transform(float64list); - verify(() => canvas.transform(float64list)).called(1); - }); - - test("translate calls Canvas's translate", () { - final zcanvas = ZCanvas()..canvas = canvas; - zcanvas.translate(0, 0); - verify(() => canvas.translate(0, 0)).called(1); - }); - }); -} diff --git a/packages/pinball_theme/assets/images/android/ball.png b/packages/pinball_theme/assets/images/android/ball.png new file mode 100644 index 00000000..b5cfbc3f Binary files /dev/null and b/packages/pinball_theme/assets/images/android/ball.png differ diff --git a/packages/pinball_theme/assets/images/dash/ball.png b/packages/pinball_theme/assets/images/dash/ball.png new file mode 100644 index 00000000..fa754cbc Binary files /dev/null and b/packages/pinball_theme/assets/images/dash/ball.png differ diff --git a/packages/pinball_theme/assets/images/dino/ball.png b/packages/pinball_theme/assets/images/dino/ball.png new file mode 100644 index 00000000..02b99c43 Binary files /dev/null and b/packages/pinball_theme/assets/images/dino/ball.png differ diff --git a/packages/pinball_theme/assets/images/sparky/ball.png b/packages/pinball_theme/assets/images/sparky/ball.png new file mode 100644 index 00000000..95e5a10b Binary files /dev/null and b/packages/pinball_theme/assets/images/sparky/ball.png differ diff --git a/packages/pinball_theme/lib/src/generated/assets.gen.dart b/packages/pinball_theme/lib/src/generated/assets.gen.dart index 3feeecce..545f514b 100644 --- a/packages/pinball_theme/lib/src/generated/assets.gen.dart +++ b/packages/pinball_theme/lib/src/generated/assets.gen.dart @@ -36,9 +36,9 @@ class $AssetsImagesAndroidGen { AssetGenImage get background => const AssetGenImage('assets/images/android/background.png'); - /// File path: assets/images/android/character.png - AssetGenImage get character => - const AssetGenImage('assets/images/android/character.png'); + /// File path: assets/images/android/ball.png + AssetGenImage get ball => + const AssetGenImage('assets/images/android/ball.png'); /// File path: assets/images/android/icon.png AssetGenImage get icon => @@ -60,9 +60,8 @@ class $AssetsImagesDashGen { AssetGenImage get background => const AssetGenImage('assets/images/dash/background.png'); - /// File path: assets/images/dash/character.png - AssetGenImage get character => - const AssetGenImage('assets/images/dash/character.png'); + /// File path: assets/images/dash/ball.png + AssetGenImage get ball => const AssetGenImage('assets/images/dash/ball.png'); /// File path: assets/images/dash/icon.png AssetGenImage get icon => const AssetGenImage('assets/images/dash/icon.png'); @@ -83,9 +82,8 @@ class $AssetsImagesDinoGen { AssetGenImage get background => const AssetGenImage('assets/images/dino/background.png'); - /// File path: assets/images/dino/character.png - AssetGenImage get character => - const AssetGenImage('assets/images/dino/character.png'); + /// File path: assets/images/dino/ball.png + AssetGenImage get ball => const AssetGenImage('assets/images/dino/ball.png'); /// File path: assets/images/dino/icon.png AssetGenImage get icon => const AssetGenImage('assets/images/dino/icon.png'); @@ -106,9 +104,9 @@ class $AssetsImagesSparkyGen { AssetGenImage get background => const AssetGenImage('assets/images/sparky/background.png'); - /// File path: assets/images/sparky/character.png - AssetGenImage get character => - const AssetGenImage('assets/images/sparky/character.png'); + /// File path: assets/images/sparky/ball.png + AssetGenImage get ball => + const AssetGenImage('assets/images/sparky/ball.png'); /// File path: assets/images/sparky/icon.png AssetGenImage get icon => diff --git a/packages/pinball_theme/lib/src/themes/android_theme.dart b/packages/pinball_theme/lib/src/themes/android_theme.dart index 8989c717..6e7d76b2 100644 --- a/packages/pinball_theme/lib/src/themes/android_theme.dart +++ b/packages/pinball_theme/lib/src/themes/android_theme.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:pinball_theme/pinball_theme.dart'; /// {@template android_theme} @@ -12,7 +11,7 @@ class AndroidTheme extends CharacterTheme { String get name => 'Android'; @override - Color get ballColor => Colors.green; + AssetGenImage get ball => Assets.images.android.ball; @override AssetGenImage get background => Assets.images.android.background; diff --git a/packages/pinball_theme/lib/src/themes/character_theme.dart b/packages/pinball_theme/lib/src/themes/character_theme.dart index 072c917f..596f41a0 100644 --- a/packages/pinball_theme/lib/src/themes/character_theme.dart +++ b/packages/pinball_theme/lib/src/themes/character_theme.dart @@ -1,5 +1,4 @@ import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; import 'package:pinball_theme/pinball_theme.dart'; /// {@template character_theme} @@ -15,8 +14,8 @@ abstract class CharacterTheme extends Equatable { /// Name of character. String get name; - /// Ball color for this theme. - Color get ballColor; + /// Asset for the ball. + AssetGenImage get ball; /// Asset for the background. AssetGenImage get background; @@ -33,7 +32,7 @@ abstract class CharacterTheme extends Equatable { @override List get props => [ name, - ballColor, + ball, background, icon, leaderboardIcon, diff --git a/packages/pinball_theme/lib/src/themes/dash_theme.dart b/packages/pinball_theme/lib/src/themes/dash_theme.dart index 7584c8ed..be3a8873 100644 --- a/packages/pinball_theme/lib/src/themes/dash_theme.dart +++ b/packages/pinball_theme/lib/src/themes/dash_theme.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:pinball_theme/pinball_theme.dart'; /// {@template dash_theme} @@ -12,7 +11,7 @@ class DashTheme extends CharacterTheme { String get name => 'Dash'; @override - Color get ballColor => Colors.blue; + AssetGenImage get ball => Assets.images.dash.ball; @override AssetGenImage get background => Assets.images.dash.background; diff --git a/packages/pinball_theme/lib/src/themes/dino_theme.dart b/packages/pinball_theme/lib/src/themes/dino_theme.dart index 3baf466c..1de42d41 100644 --- a/packages/pinball_theme/lib/src/themes/dino_theme.dart +++ b/packages/pinball_theme/lib/src/themes/dino_theme.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:pinball_theme/pinball_theme.dart'; /// {@template dino_theme} @@ -12,7 +11,7 @@ class DinoTheme extends CharacterTheme { String get name => 'Dino'; @override - Color get ballColor => Colors.grey; + AssetGenImage get ball => Assets.images.dino.ball; @override AssetGenImage get background => Assets.images.dino.background; diff --git a/packages/pinball_theme/lib/src/themes/sparky_theme.dart b/packages/pinball_theme/lib/src/themes/sparky_theme.dart index 7884a22f..1699f3ae 100644 --- a/packages/pinball_theme/lib/src/themes/sparky_theme.dart +++ b/packages/pinball_theme/lib/src/themes/sparky_theme.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:pinball_theme/pinball_theme.dart'; /// {@template sparky_theme} @@ -9,7 +8,7 @@ class SparkyTheme extends CharacterTheme { const SparkyTheme(); @override - Color get ballColor => Colors.orange; + AssetGenImage get ball => Assets.images.sparky.ball; @override String get name => 'Sparky'; diff --git a/packages/pinball_ui/lib/src/theme/pinball_colors.dart b/packages/pinball_ui/lib/src/theme/pinball_colors.dart index df1ddce6..d6029422 100644 --- a/packages/pinball_ui/lib/src/theme/pinball_colors.dart +++ b/packages/pinball_ui/lib/src/theme/pinball_colors.dart @@ -6,6 +6,7 @@ abstract class PinballColors { static const Color darkBlue = Color(0xFF0C32A4); static const Color yellow = Color(0xFFFFEE02); static const Color orange = Color(0xFFE5AB05); + static const Color red = Color(0xFFF03939); static const Color blue = Color(0xFF4B94F6); static const Color transparent = Color(0x00000000); static const Color loadingDarkRed = Color(0xFFE33B2D); diff --git a/packages/pinball_ui/test/src/theme/pinball_colors_test.dart b/packages/pinball_ui/test/src/theme/pinball_colors_test.dart index 3c54c60b..469ab142 100644 --- a/packages/pinball_ui/test/src/theme/pinball_colors_test.dart +++ b/packages/pinball_ui/test/src/theme/pinball_colors_test.dart @@ -20,6 +20,10 @@ void main() { expect(PinballColors.orange, const Color(0xFFE5AB05)); }); + test('red is 0xFFF03939', () { + expect(PinballColors.red, const Color(0xFFF03939)); + }); + test('blue is 0xFF4B94F6', () { expect(PinballColors.blue, const Color(0xFF4B94F6)); }); diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index ca1cedff..4f04a89d 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -9,7 +9,7 @@ import 'package:pinball_audio/pinball_audio.dart'; class _MockAuthenticationRepository extends Mock implements AuthenticationRepository {} -class _MockPinballAudio extends Mock implements PinballAudio {} +class _MockPinballPlayer extends Mock implements PinballPlayer {} class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { } @@ -18,13 +18,13 @@ void main() { group('App', () { late AuthenticationRepository authenticationRepository; late LeaderboardRepository leaderboardRepository; - late PinballAudio pinballAudio; + late PinballPlayer pinballPlayer; setUp(() { authenticationRepository = _MockAuthenticationRepository(); leaderboardRepository = _MockLeaderboardRepository(); - pinballAudio = _MockPinballAudio(); - when(pinballAudio.load).thenAnswer((_) => Future.value()); + pinballPlayer = _MockPinballPlayer(); + when(pinballPlayer.load).thenAnswer((_) => [Future.value()]); }); testWidgets('renders PinballGamePage', (tester) async { @@ -32,7 +32,7 @@ void main() { App( authenticationRepository: authenticationRepository, leaderboardRepository: leaderboardRepository, - pinballAudio: pinballAudio, + pinballPlayer: pinballPlayer, ), ); expect(find.byType(PinballGamePage), findsOneWidget); diff --git a/test/game/behaviors/bumper_noisy_behavior_test.dart b/test/game/behaviors/bumper_noisy_behavior_test.dart new file mode 100644 index 00000000..18d90fbd --- /dev/null +++ b/test/game/behaviors/bumper_noisy_behavior_test.dart @@ -0,0 +1,50 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball_audio/pinball_audio.dart'; + +import '../../helpers/helpers.dart'; + +class _TestBodyComponent extends BodyComponent { + @override + Body createBody() { + return world.createBody(BodyDef()); + } +} + +class _MockPinballPlayer extends Mock implements PinballPlayer {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('BumperNoisyBehavior', () {}); + + late PinballPlayer player; + final flameTester = FlameTester( + () => EmptyPinballTestGame(player: player), + ); + + setUp(() { + player = _MockPinballPlayer(); + }); + + flameTester.testGameWidget( + 'plays bumper sound', + setUp: (game, _) async { + final behavior = BumperNoisyBehavior(); + final parent = _TestBodyComponent(); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + behavior.beginContact(Object(), _MockContact()); + }, + verify: (_, __) async { + verify(() => player.play(PinballAudio.bumper)).called(1); + }, + ); +} diff --git a/test/game/behaviors/scoring_behavior_test.dart b/test/game/behaviors/scoring_behavior_test.dart new file mode 100644 index 00000000..5673e165 --- /dev/null +++ b/test/game/behaviors/scoring_behavior_test.dart @@ -0,0 +1,212 @@ +// 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/behaviors/behaviors.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'; + +class _TestBodyComponent extends BodyComponent { + @override + Body createBody() => world.createBody(BodyDef()); +} + +class _MockBall extends Mock implements Ball {} + +class _MockBody extends Mock implements Body {} + +class _MockGameBloc extends Mock implements GameBloc {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.score.fiveThousand.keyName, + Assets.images.score.twentyThousand.keyName, + Assets.images.score.twoHundredThousand.keyName, + Assets.images.score.oneMillion.keyName, + ]; + + late GameBloc bloc; + late Ball ball; + late BodyComponent parent; + + setUp(() { + ball = _MockBall(); + final ballBody = _MockBody(); + when(() => ball.body).thenReturn(ballBody); + when(() => ballBody.position).thenReturn(Vector2.all(4)); + + parent = _TestBodyComponent(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () { + bloc = _MockGameBloc(); + const state = GameState( + totalScore: 0, + roundScore: 0, + multiplier: 1, + rounds: 3, + bonusHistory: [], + status: GameStatus.playing, + ); + whenListen(bloc, Stream.value(state), initialState: state); + return bloc; + }, + assets: assets, + ); + + group('ScoringBehavior', () { + test('can be instantiated', () { + expect( + ScoringBehavior( + points: Points.fiveThousand, + position: Vector2.zero(), + ), + isA(), + ); + }); + + flameBlocTester.testGameWidget( + 'can be loaded', + setUp: (game, tester) async { + final canvas = ZCanvasComponent(children: [parent]); + final behavior = ScoringBehavior( + points: Points.fiveThousand, + position: Vector2.zero(), + ); + await parent.add(behavior); + await game.ensureAdd(canvas); + + expect( + parent.firstChild(), + equals(behavior), + ); + }, + ); + + flameBlocTester.testGameWidget( + 'emits Scored event with points when added', + setUp: (game, tester) async { + const points = Points.oneMillion; + final canvas = ZCanvasComponent(children: [parent]); + await game.ensureAdd(canvas); + + final behavior = ScoringBehavior( + points: points, + position: Vector2(0, 0), + ); + await parent.ensureAdd(behavior); + + verify( + () => bloc.add( + Scored(points: points.value), + ), + ).called(1); + }, + ); + + flameBlocTester.testGameWidget( + 'correctly renders text', + setUp: (game, tester) async { + final canvas = ZCanvasComponent(children: [parent]); + await game.ensureAdd(canvas); + + const points = Points.oneMillion; + final position = Vector2.all(1); + final behavior = ScoringBehavior( + points: points, + position: position, + ); + await parent.ensureAdd(behavior); + + final scoreText = game.descendants().whereType(); + expect(scoreText.length, equals(1)); + expect( + scoreText.first.points, + equals(points), + ); + expect( + scoreText.first.position, + equals(position), + ); + }, + ); + + flameBlocTester.testGameWidget( + 'is removed after duration', + setUp: (game, tester) async { + final canvas = ZCanvasComponent(children: [parent]); + await game.ensureAdd(canvas); + + const duration = 2.0; + final behavior = ScoringBehavior( + points: Points.oneMillion, + position: Vector2(0, 0), + duration: duration, + ); + await parent.ensureAdd(behavior); + + game.update(duration); + game.update(0); + await tester.pump(); + }, + verify: (game, _) async { + expect( + game.descendants().whereType(), + isEmpty, + ); + }, + ); + }); + + group('ScoringContactBehavior', () { + flameBlocTester.testGameWidget( + 'beginContact adds a ScoringBehavior', + setUp: (game, tester) async { + final canvas = ZCanvasComponent(children: [parent]); + await game.ensureAdd(canvas); + + final behavior = ScoringContactBehavior(points: Points.oneMillion); + await parent.ensureAdd(behavior); + + behavior.beginContact(ball, _MockContact()); + await game.ready(); + + expect( + parent.firstChild(), + isNotNull, + ); + }, + ); + + flameBlocTester.testGameWidget( + "beginContact positions text at contact's position", + setUp: (game, tester) async { + final canvas = ZCanvasComponent(children: [parent]); + await game.ensureAdd(canvas); + + final behavior = ScoringContactBehavior(points: Points.oneMillion); + await parent.ensureAdd(behavior); + + behavior.beginContact(ball, _MockContact()); + await game.ready(); + + final scoreText = game.descendants().whereType(); + expect( + scoreText.first.position, + equals(ball.body.position), + ); + }, + ); + }); +} diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart index 3711105e..3e5abb74 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -6,10 +6,38 @@ void main() { group('GameBloc', () { test('initial state has 3 rounds and empty score', () { final gameBloc = GameBloc(); - expect(gameBloc.state.score, equals(0)); + expect(gameBloc.state.roundScore, equals(0)); expect(gameBloc.state.rounds, equals(3)); }); + blocTest( + 'GameStarted starts the game', + build: GameBloc.new, + act: (bloc) => bloc.add(const GameStarted()), + expect: () => [ + isA() + ..having( + (state) => state.status, + 'status', + GameStatus.playing, + ), + ], + ); + + blocTest( + 'GameOver finishes the game', + build: GameBloc.new, + act: (bloc) => bloc.add(const GameOver()), + expect: () => [ + isA() + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), + ], + ); + group('RoundLost', () { blocTest( 'decreases number of rounds ' @@ -19,77 +47,92 @@ void main() { bloc.add(const RoundLost()); }, expect: () => [ - const GameState( - score: 0, - multiplier: 1, - rounds: 2, - bonusHistory: [], - ), + isA()..having((state) => state.rounds, 'rounds', 2), + ], + ); + + blocTest( + 'sets game over when there are no more rounds', + build: GameBloc.new, + act: (bloc) { + bloc + ..add(const RoundLost()) + ..add(const RoundLost()) + ..add(const RoundLost()); + }, + expect: () => [ + isA()..having((state) => state.rounds, 'rounds', 2), + isA()..having((state) => state.rounds, 'rounds', 1), + isA() + ..having((state) => state.rounds, 'rounds', 0) + ..having((state) => state.status, 'status', GameStatus.gameOver), ], ); blocTest( - 'apply multiplier to score ' + 'apply multiplier to roundScore and add it to totalScore ' 'when round is lost', build: GameBloc.new, seed: () => const GameState( - score: 5, + totalScore: 10, + roundScore: 5, multiplier: 3, rounds: 2, bonusHistory: [], + status: GameStatus.playing, ), act: (bloc) { bloc.add(const RoundLost()); }, expect: () => [ isA() - ..having((state) => state.score, 'score', 15) - ..having((state) => state.rounds, 'rounds', 1), + ..having((state) => state.totalScore, 'totalScore', 25) + ..having((state) => state.roundScore, 'roundScore', 0) ], ); blocTest( - 'resets multiplier ' - 'when round is lost', + 'resets multiplier when round is lost', build: GameBloc.new, seed: () => const GameState( - score: 5, + totalScore: 10, + roundScore: 5, multiplier: 3, rounds: 2, bonusHistory: [], + status: GameStatus.playing, ), act: (bloc) { bloc.add(const RoundLost()); }, expect: () => [ - isA() - ..having((state) => state.multiplier, 'multiplier', 1) - ..having((state) => state.rounds, 'rounds', 1), + isA()..having((state) => state.multiplier, 'multiplier', 1) ], ); }); group('Scored', () { blocTest( - 'increases score ' - 'when game is not over', + 'increases score when playing', build: GameBloc.new, act: (bloc) => bloc + ..add(const GameStarted()) ..add(const Scored(points: 2)) ..add(const Scored(points: 3)), expect: () => [ isA() - ..having((state) => state.score, 'score', 2) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having((state) => state.status, 'status', GameStatus.playing), isA() - ..having((state) => state.score, 'score', 5) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having((state) => state.roundScore, 'roundScore', 2) + ..having((state) => state.status, 'status', GameStatus.playing), + isA() + ..having((state) => state.roundScore, 'roundScore', 5) + ..having((state) => state.status, 'status', GameStatus.playing), ], ); blocTest( - "doesn't increase score " - 'when game is over', + "doesn't increase score when game is over", build: GameBloc.new, act: (bloc) { for (var i = 0; i < bloc.state.rounds; i++) { @@ -99,17 +142,29 @@ void main() { }, expect: () => [ isA() - ..having((state) => state.score, 'score', 0) + ..having((state) => state.roundScore, 'roundScore', 0) ..having((state) => state.rounds, 'rounds', 2) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), isA() - ..having((state) => state.score, 'score', 0) + ..having((state) => state.roundScore, 'roundScore', 0) ..having((state) => state.rounds, 'rounds', 1) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), isA() - ..having((state) => state.score, 'score', 0) + ..having((state) => state.roundScore, 'roundScore', 0) ..having((state) => state.rounds, 'rounds', 0) - ..having((state) => state.isGameOver, 'isGameOver', true), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), ], ); }); @@ -120,17 +175,26 @@ void main() { 'when multiplier is below 6 and game is not over', build: GameBloc.new, act: (bloc) => bloc + ..add(const GameStarted()) ..add(const MultiplierIncreased()) ..add(const MultiplierIncreased()), expect: () => [ isA() - ..having((state) => state.score, 'score', 0) + ..having((state) => state.status, 'status', GameStatus.playing), + isA() ..having((state) => state.multiplier, 'multiplier', 2) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 3) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), ], ); @@ -139,10 +203,12 @@ void main() { 'when multiplier is 6 and game is not over', build: GameBloc.new, seed: () => const GameState( - score: 0, + totalScore: 10, + roundScore: 0, multiplier: 6, rounds: 3, bonusHistory: [], + status: GameStatus.playing, ), act: (bloc) => bloc..add(const MultiplierIncreased()), expect: () => const [], @@ -153,6 +219,7 @@ void main() { 'when game is over', build: GameBloc.new, act: (bloc) { + bloc.add(const GameStarted()); for (var i = 0; i < bloc.state.rounds; i++) { bloc.add(const RoundLost()); } @@ -160,17 +227,28 @@ void main() { }, expect: () => [ isA() - ..having((state) => state.score, 'score', 0) + ..having((state) => state.status, 'status', GameStatus.playing), + isA() ..having((state) => state.multiplier, 'multiplier', 1) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 1) - ..having((state) => state.isGameOver, 'isGameOver', false), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 1) - ..having((state) => state.isGameOver, 'isGameOver', true), + ..having( + (state) => state.status, + 'status', + GameStatus.gameOver, + ), ], ); }); diff --git a/test/game/bloc/game_event_test.dart b/test/game/bloc/game_event_test.dart index 6a39bd67..c4de5792 100644 --- a/test/game/bloc/game_event_test.dart +++ b/test/game/bloc/game_event_test.dart @@ -72,6 +72,32 @@ void main() { }); }); + group('GameStarted', () { + test('can be instantiated', () { + expect(const GameStarted(), isNotNull); + }); + + test('supports value equality', () { + expect( + GameStarted(), + equals(const GameStarted()), + ); + }); + }); + + group('GameOver', () { + test('can be instantiated', () { + expect(const GameOver(), isNotNull); + }); + + test('supports value equality', () { + expect( + GameOver(), + equals(const GameOver()), + ); + }); + }); + group('SparkyTurboChargeActivated', () { test('can be instantiated', () { expect(const SparkyTurboChargeActivated(), isNotNull); diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index add25e05..670707a0 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -8,17 +8,21 @@ void main() { test('supports value equality', () { expect( GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: const [], + status: GameStatus.waiting, ), equals( const GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: [], + status: GameStatus.waiting, ), ), ); @@ -28,10 +32,12 @@ void main() { test('can be instantiated', () { expect( const GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: [], + status: GameStatus.waiting, ), isNotNull, ); @@ -40,14 +46,34 @@ void main() { test( 'throws AssertionError ' - 'when score is negative', + 'when totalScore is negative', () { expect( () => GameState( - score: -1, + totalScore: -1, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: const [], + status: GameStatus.waiting, + ), + throwsAssertionError, + ); + }, + ); + + test( + 'throws AssertionError ' + 'when roundScore is negative', + () { + expect( + () => GameState( + totalScore: 0, + roundScore: -1, + multiplier: 1, + rounds: 3, + bonusHistory: const [], + status: GameStatus.waiting, ), throwsAssertionError, ); @@ -60,10 +86,12 @@ void main() { () { expect( () => GameState( - score: 1, + totalScore: 0, + roundScore: 1, multiplier: 0, rounds: 3, bonusHistory: const [], + status: GameStatus.waiting, ), throwsAssertionError, ); @@ -76,55 +104,33 @@ void main() { () { expect( () => GameState( - score: 1, + totalScore: 0, + roundScore: 1, multiplier: 1, rounds: -1, bonusHistory: const [], + status: GameStatus.waiting, ), throwsAssertionError, ); }, ); - group('isGameOver', () { - test( - 'is true ' - 'when no rounds are left', () { - const gameState = GameState( - score: 0, - multiplier: 1, - rounds: 0, - bonusHistory: [], - ); - expect(gameState.isGameOver, isTrue); - }); - - test( - 'is false ' - 'when one 1 round left', () { - const gameState = GameState( - score: 0, - multiplier: 1, - rounds: 1, - bonusHistory: [], - ); - expect(gameState.isGameOver, isFalse); - }); - }); - group('copyWith', () { test( 'throws AssertionError ' - 'when scored is decreased', + 'when totalScore is decreased', () { const gameState = GameState( - score: 2, + totalScore: 2, + roundScore: 2, multiplier: 1, rounds: 3, bonusHistory: [], + status: GameStatus.waiting, ); expect( - () => gameState.copyWith(score: gameState.score - 1), + () => gameState.copyWith(totalScore: gameState.totalScore - 1), throwsAssertionError, ); }, @@ -135,10 +141,12 @@ void main() { 'when no argument specified', () { const gameState = GameState( - score: 2, + totalScore: 0, + roundScore: 2, multiplier: 1, rounds: 3, bonusHistory: [], + status: GameStatus.waiting, ); expect( gameState.copyWith(), @@ -152,25 +160,31 @@ void main() { 'when all arguments specified', () { const gameState = GameState( - score: 2, + totalScore: 0, + roundScore: 2, multiplier: 1, rounds: 3, bonusHistory: [], + status: GameStatus.waiting, ); final otherGameState = GameState( - score: gameState.score + 1, + totalScore: gameState.totalScore + 1, + roundScore: gameState.roundScore + 1, multiplier: gameState.multiplier + 1, rounds: gameState.rounds + 1, bonusHistory: const [GameBonus.googleWord], + status: GameStatus.playing, ); expect(gameState, isNot(equals(otherGameState))); expect( gameState.copyWith( - score: otherGameState.score, + totalScore: otherGameState.totalScore, + roundScore: otherGameState.roundScore, multiplier: otherGameState.multiplier, rounds: otherGameState.rounds, bonusHistory: otherGameState.bonusHistory, + status: otherGameState.status, ), equals(otherGameState), ); diff --git a/test/game/components/android_acres/android_acres_test.dart b/test/game/components/android_acres/android_acres_test.dart index 73025551..8434d5f8 100644 --- a/test/game/components/android_acres/android_acres_test.dart +++ b/test/game/components/android_acres/android_acres_test.dart @@ -2,6 +2,7 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/behaviors/bumper_noisy_behavior.dart'; import 'package:pinball/game/components/android_acres/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -33,11 +34,12 @@ void main() { Assets.images.android.bumper.cow.lit.keyName, Assets.images.android.bumper.cow.dimmed.keyName, ]; - final flameTester = FlameTester( - () => EmptyPinballTestGame(assets: assets), - ); group('AndroidAcres', () { + final flameTester = FlameTester( + () => EmptyPinballTestGame(assets: assets), + ); + flameTester.test('loads correctly', (game) async { final component = AndroidAcres(); await game.ensureAdd(component); @@ -99,6 +101,20 @@ void main() { ); }, ); + + flameTester.test( + 'three AndroidBumpers with BumperNoisyBehavior', + (game) async { + await game.ensureAdd(AndroidAcres()); + final bumpers = game.descendants().whereType(); + for (final bumper in bumpers) { + expect( + bumper.firstChild(), + isNotNull, + ); + } + }, + ); }); flameTester.test('adds an AndroidSpaceshipBonusBehavior', (game) async { diff --git a/test/game/components/android_acres/behaviors/ramp_bonus_behavior_test.dart b/test/game/components/android_acres/behaviors/ramp_bonus_behavior_test.dart new file mode 100644 index 00000000..acd17717 --- /dev/null +++ b/test/game/components/android_acres/behaviors/ramp_bonus_behavior_test.dart @@ -0,0 +1,152 @@ +// ignore_for_file: cascade_invocations, prefer_const_constructors + +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/behaviors/behaviors.dart'; +import 'package:pinball/game/components/android_acres/behaviors/behaviors.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'; + +class _MockGameBloc extends Mock implements GameBloc {} + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} + +class _MockStreamSubscription extends Mock + implements StreamSubscription {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.android.ramp.boardOpening.keyName, + Assets.images.android.ramp.railingForeground.keyName, + Assets.images.android.ramp.railingBackground.keyName, + Assets.images.android.ramp.main.keyName, + Assets.images.android.ramp.arrow.inactive.keyName, + Assets.images.android.ramp.arrow.active1.keyName, + Assets.images.android.ramp.arrow.active2.keyName, + Assets.images.android.ramp.arrow.active3.keyName, + Assets.images.android.ramp.arrow.active4.keyName, + Assets.images.android.ramp.arrow.active5.keyName, + Assets.images.android.rail.main.keyName, + Assets.images.android.rail.exit.keyName, + Assets.images.score.oneMillion.keyName, + ]; + + group('RampBonusBehavior', () { + const shotPoints = Points.oneMillion; + + 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, + ); + + flameBlocTester.testGameWidget( + 'when hits are multiples of 10 times adds a ScoringBehavior', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState(hits: 9), + ); + final behavior = RampBonusBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 10)); + + final scores = game.descendants().whereType(); + await game.ready(); + + expect(scores.length, 1); + }, + ); + + flameBlocTester.testGameWidget( + "when hits are not multiple of 10 times doesn't add any ScoringBehavior", + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState.initial(), + ); + final behavior = RampBonusBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 1)); + + final scores = game.descendants().whereType(); + await game.ready(); + + expect(scores.length, 0); + }, + ); + + flameBlocTester.testGameWidget( + 'closes subscription when removed', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SpaceshipRampState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + + final subscription = _MockStreamSubscription(); + when(subscription.cancel).thenAnswer((_) async {}); + + final behavior = RampBonusBehavior.test( + points: shotPoints, + subscription: subscription, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + parent.remove(behavior); + await game.ready(); + + verify(subscription.cancel).called(1); + }, + ); + }); +} diff --git a/test/game/components/android_acres/behaviors/ramp_shot_behavior_test.dart b/test/game/components/android_acres/behaviors/ramp_shot_behavior_test.dart new file mode 100644 index 00000000..23f02220 --- /dev/null +++ b/test/game/components/android_acres/behaviors/ramp_shot_behavior_test.dart @@ -0,0 +1,156 @@ +// ignore_for_file: cascade_invocations, prefer_const_constructors + +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/behaviors/behaviors.dart'; +import 'package:pinball/game/components/android_acres/behaviors/behaviors.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'; + +class _MockGameBloc extends Mock implements GameBloc {} + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} + +class _MockStreamSubscription extends Mock + implements StreamSubscription {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.android.ramp.boardOpening.keyName, + Assets.images.android.ramp.railingForeground.keyName, + Assets.images.android.ramp.railingBackground.keyName, + Assets.images.android.ramp.main.keyName, + Assets.images.android.ramp.arrow.inactive.keyName, + Assets.images.android.ramp.arrow.active1.keyName, + Assets.images.android.ramp.arrow.active2.keyName, + Assets.images.android.ramp.arrow.active3.keyName, + Assets.images.android.ramp.arrow.active4.keyName, + Assets.images.android.ramp.arrow.active5.keyName, + Assets.images.android.rail.main.keyName, + Assets.images.android.rail.exit.keyName, + Assets.images.score.fiveThousand.keyName, + ]; + + group('RampShotBehavior', () { + const shotPoints = Points.fiveThousand; + + 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, + ); + + flameBlocTester.testGameWidget( + 'when hits are not multiple of 10 times ' + 'increases multiplier and adds a ScoringBehavior', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState.initial(), + ); + final behavior = RampShotBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 1)); + + final scores = game.descendants().whereType(); + await game.ready(); + + verify(() => gameBloc.add(MultiplierIncreased())).called(1); + expect(scores.length, 1); + }, + ); + + flameBlocTester.testGameWidget( + 'when hits multiple of 10 times ' + "doesn't increase multiplier, neither ScoringBehavior", + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState(hits: 9), + ); + final behavior = RampShotBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 10)); + + final scores = game.children.whereType(); + await game.ready(); + + verifyNever(() => gameBloc.add(MultiplierIncreased())); + expect(scores.length, 0); + }, + ); + + flameBlocTester.testGameWidget( + 'closes subscription when removed', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SpaceshipRampState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + + final subscription = _MockStreamSubscription(); + when(subscription.cancel).thenAnswer((_) async {}); + + final behavior = RampShotBehavior.test( + points: shotPoints, + subscription: subscription, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + parent.remove(behavior); + await game.ready(); + + verify(subscription.cancel).called(1); + }, + ); + }); +} diff --git a/test/game/components/backbox/backbox_test.dart b/test/game/components/backbox/backbox_test.dart new file mode 100644 index 00000000..33d43aa8 --- /dev/null +++ b/test/game/components/backbox/backbox_test.dart @@ -0,0 +1,245 @@ +// ignore_for_file: cascade_invocations, prefer_const_constructors + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball/game/components/backbox/displays/displays.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; + +import '../../../helpers/helpers.dart'; + +class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} + +RawKeyUpEvent _mockKeyUp(LogicalKeyboardKey key) { + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn(key); + return event; +} + +class _MockBackboxBloc extends Mock implements BackboxBloc {} + +class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { +} + +class _MockAppLocalizations extends Mock implements AppLocalizations { + @override + String get score => ''; + + @override + String get name => ''; + + @override + String get enterInitials => ''; + + @override + String get arrows => ''; + + @override + String get andPress => ''; + + @override + String get enterReturn => ''; + + @override + String get toSubmit => ''; + + @override + String get loading => ''; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const character = theme.AndroidTheme(); + final assets = [ + character.leaderboardIcon.keyName, + Assets.images.backbox.marquee.keyName, + Assets.images.backbox.displayDivider.keyName, + ]; + final flameTester = FlameTester( + () => EmptyPinballTestGame( + assets: assets, + l10n: _MockAppLocalizations(), + ), + ); + + late BackboxBloc bloc; + + setUp(() { + bloc = _MockBackboxBloc(); + whenListen( + bloc, + Stream.value(LoadingState()), + initialState: LoadingState(), + ); + }); + + group('Backbox', () { + flameTester.test( + 'loads correctly', + (game) async { + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + expect(game.children, contains(backbox)); + }, + ); + + flameTester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + await game.images.loadAll(assets); + game.camera + ..followVector2(Vector2(0, -130)) + ..zoom = 6; + await game.ensureAdd( + Backbox.test(bloc: bloc), + ); + await tester.pump(); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('../golden/backbox.png'), + ); + }, + ); + + flameTester.test( + 'requestInitials adds InitialsInputDisplay', + (game) async { + final backbox = Backbox.test( + bloc: BackboxBloc( + leaderboardRepository: _MockLeaderboardRepository(), + ), + ); + await game.ensureAdd(backbox); + backbox.requestInitials( + score: 0, + character: character, + ); + await game.ready(); + + expect( + backbox.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds PlayerInitialsSubmitted when initials are submitted', + (game) async { + final bloc = _MockBackboxBloc(); + final state = InitialsFormState( + score: 10, + character: theme.AndroidTheme(), + ); + whenListen( + bloc, + Stream.value(state), + initialState: state, + ); + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + game.onKeyEvent(_mockKeyUp(LogicalKeyboardKey.enter), {}); + verify( + () => bloc.add( + PlayerInitialsSubmitted( + score: 10, + initials: 'AAA', + character: theme.AndroidTheme(), + ), + ), + ).called(1); + }, + ); + + flameTester.test( + 'adds InitialsSubmissionSuccessDisplay on InitialsSuccessState', + (game) async { + whenListen( + bloc, + Stream.value(InitialsSuccessState()), + initialState: InitialsSuccessState(), + ); + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + expect( + game + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds InitialsSubmissionFailureDisplay on InitialsFailureState', + (game) async { + whenListen( + bloc, + Stream.value(InitialsFailureState()), + initialState: InitialsFailureState(), + ); + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + expect( + game + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); + + flameTester.test( + 'closes the subscription when it is removed', + (game) async { + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: LoadingState(), + ); + + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + backbox.removeFromParent(); + await game.ready(); + + streamController.add(InitialsFailureState()); + await game.ready(); + + expect( + backbox + .descendants() + .whereType() + .isEmpty, + isTrue, + ); + }, + ); + }); +} diff --git a/test/game/components/backbox/bloc/backbox_bloc_test.dart b/test/game/components/backbox/bloc/backbox_bloc_test.dart new file mode 100644 index 00000000..c2fbc088 --- /dev/null +++ b/test/game/components/backbox/bloc/backbox_bloc_test.dart @@ -0,0 +1,92 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { +} + +void main() { + late LeaderboardRepository leaderboardRepository; + + group('BackboxBloc', () { + blocTest( + 'adds InitialsFormState on PlayerInitialsRequested', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add( + PlayerInitialsRequested( + score: 100, + character: AndroidTheme(), + ), + ), + expect: () => [ + InitialsFormState(score: 100, character: AndroidTheme()), + ], + ); + + group('PlayerInitialsSubmitted', () { + blocTest( + 'adds [LoadingState, InitialsSuccessState] when submission succeeds', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + when( + () => leaderboardRepository.addLeaderboardEntry( + LeaderboardEntryData( + playerInitials: 'AAA', + score: 10, + character: CharacterType.dash, + ), + ), + ).thenAnswer((_) async {}); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add( + PlayerInitialsSubmitted( + score: 10, + initials: 'AAA', + character: DashTheme(), + ), + ), + expect: () => [ + LoadingState(), + InitialsSuccessState(), + ], + ); + + blocTest( + 'adds [LoadingState, InitialsFailureState] when submission fails', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + when( + () => leaderboardRepository.addLeaderboardEntry( + LeaderboardEntryData( + playerInitials: 'AAA', + score: 10, + character: CharacterType.dash, + ), + ), + ).thenThrow(Exception('Error')); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add( + PlayerInitialsSubmitted( + score: 10, + initials: 'AAA', + character: DashTheme(), + ), + ), + expect: () => [ + LoadingState(), + InitialsFailureState(), + ], + ); + }); + }); +} diff --git a/test/game/components/backbox/bloc/backbox_event_test.dart b/test/game/components/backbox/bloc/backbox_event_test.dart new file mode 100644 index 00000000..5fc766a9 --- /dev/null +++ b/test/game/components/backbox/bloc/backbox_event_test.dart @@ -0,0 +1,126 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('BackboxEvent', () { + group('PlayerInitialsRequested', () { + test('can be instantiated', () { + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + equals( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + ), + ); + + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + isNot( + equals( + PlayerInitialsRequested(score: 1, character: AndroidTheme()), + ), + ), + ); + + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + isNot( + equals( + PlayerInitialsRequested(score: 0, character: SparkyTheme()), + ), + ), + ); + }); + }); + + group('PlayerInitialsSubmitted', () { + test('can be instantiated', () { + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + equals( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + ); + + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + PlayerInitialsSubmitted( + score: 1, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + ), + ); + + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: SparkyTheme(), + ), + ), + ), + ); + + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + PlayerInitialsSubmitted( + score: 0, + initials: 'BBB', + character: AndroidTheme(), + ), + ), + ), + ); + }); + }); + }); +} diff --git a/test/game/components/backbox/bloc/backbox_state_test.dart b/test/game/components/backbox/bloc/backbox_state_test.dart new file mode 100644 index 00000000..4708c9bb --- /dev/null +++ b/test/game/components/backbox/bloc/backbox_state_test.dart @@ -0,0 +1,116 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('BackboxState', () { + group('LoadingState', () { + test('can be instantiated', () { + expect(LoadingState(), isNotNull); + }); + + test('supports value comparison', () { + expect(LoadingState(), equals(LoadingState())); + }); + }); + + group('LeaderboardSuccessState', () { + test('can be instantiated', () { + expect(LeaderboardSuccessState(), isNotNull); + }); + + test('supports value comparison', () { + expect(LeaderboardSuccessState(), equals(LeaderboardSuccessState())); + }); + }); + + group('LeaderboardFailureState', () { + test('can be instantiated', () { + expect(LeaderboardFailureState(), isNotNull); + }); + + test('supports value comparison', () { + expect(LeaderboardFailureState(), equals(LeaderboardFailureState())); + }); + }); + + group('InitialsFormState', () { + test('can be instantiated', () { + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + equals( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + ), + ); + + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + isNot( + equals( + InitialsFormState( + score: 1, + character: AndroidTheme(), + ), + ), + ), + ); + + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + isNot( + equals( + InitialsFormState( + score: 0, + character: SparkyTheme(), + ), + ), + ), + ); + }); + }); + + group('InitialsSuccessState', () { + test('can be instantiated', () { + expect(InitialsSuccessState(), isNotNull); + }); + + test('supports value comparison', () { + expect(InitialsSuccessState(), equals(InitialsSuccessState())); + }); + + group('InitialsFailureState', () { + test('can be instantiated', () { + expect(InitialsFailureState(), isNotNull); + }); + + test('supports value comparison', () { + expect(InitialsFailureState(), equals(InitialsFailureState())); + }); + }); + }); + }); +} diff --git a/test/game/components/backbox/displays/initials_input_display_test.dart b/test/game/components/backbox/displays/initials_input_display_test.dart new file mode 100644 index 00000000..993e0678 --- /dev/null +++ b/test/game/components/backbox/displays/initials_input_display_test.dart @@ -0,0 +1,189 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/components/backbox/displays/initials_input_display.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; + +import '../../../../helpers/helpers.dart'; + +class _MockAppLocalizations extends Mock implements AppLocalizations { + @override + String get score => ''; + + @override + String get name => ''; + + @override + String get enterInitials => ''; + + @override + String get arrows => ''; + + @override + String get andPress => ''; + + @override + String get enterReturn => ''; + + @override + String get toSubmit => ''; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final characterIconPath = theme.Assets.images.dash.leaderboardIcon.keyName; + final assets = [ + characterIconPath, + Assets.images.backbox.displayDivider.keyName, + ]; + final flameTester = FlameTester( + () => EmptyKeyboardPinballTestGame( + assets: assets, + l10n: _MockAppLocalizations(), + ), + ); + + group('InitialsInputDisplay', () { + flameTester.test( + 'loads correctly', + (game) async { + final initialsInputDisplay = InitialsInputDisplay( + score: 0, + characterIconPath: characterIconPath, + onSubmit: (_) {}, + ); + await game.ensureAdd(initialsInputDisplay); + + expect(game.children, contains(initialsInputDisplay)); + }, + ); + + flameTester.testGameWidget( + 'can change the initials', + setUp: (game, tester) async { + await game.images.loadAll(assets); + final initialsInputDisplay = InitialsInputDisplay( + score: 1000, + characterIconPath: characterIconPath, + onSubmit: (_) {}, + ); + await game.ensureAdd(initialsInputDisplay); + + // Focus is already on the first letter + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + // Move to the next an press up again + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + // One more time + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + // Back to the previous and increase one more + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + }, + verify: (game, tester) async { + final initialsInputDisplay = + game.descendants().whereType().single; + + expect(initialsInputDisplay.initials, equals('BCB')); + }, + ); + + String? submitedInitials; + flameTester.testGameWidget( + 'submits the initials', + setUp: (game, tester) async { + await game.images.loadAll(assets); + final initialsInputDisplay = InitialsInputDisplay( + score: 1000, + characterIconPath: characterIconPath, + onSubmit: (value) { + submitedInitials = value; + }, + ); + await game.ensureAdd(initialsInputDisplay); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + }, + verify: (game, tester) async { + expect(submitedInitials, equals('AAA')); + }, + ); + + group('BackboardLetterPrompt', () { + flameTester.testGameWidget( + 'cycles the char up and down when it has focus', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd( + InitialsLetterPrompt(hasFocus: true, position: Vector2.zero()), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + }, + verify: (game, tester) async { + final prompt = game.firstChild(); + expect(prompt?.char, equals('C')); + }, + ); + + flameTester.testGameWidget( + "does nothing when it doesn't have focus", + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd( + InitialsLetterPrompt(position: Vector2.zero()), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + }, + verify: (game, tester) async { + final prompt = game.firstChild(); + expect(prompt?.char, equals('A')); + }, + ); + + flameTester.testGameWidget( + 'blinks the prompt when it has the focus', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd( + InitialsLetterPrompt(position: Vector2.zero(), hasFocus: true), + ); + }, + verify: (game, tester) async { + final underscore = + game.descendants().whereType().first; + expect(underscore.paint.color, Colors.white); + + game.update(2); + expect(underscore.paint.color, Colors.transparent); + }, + ); + }); + }); +} diff --git a/test/game/components/backbox/displays/initials_submission_failure_display_test.dart b/test/game/components/backbox/displays/initials_submission_failure_display_test.dart new file mode 100644 index 00000000..5989445f --- /dev/null +++ b/test/game/components/backbox/displays/initials_submission_failure_display_test.dart @@ -0,0 +1,22 @@ +// 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/game/components/backbox/displays/initials_submission_failure_display.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + group('InitialsSubmissionFailureDisplay', () { + final flameTester = FlameTester(EmptyKeyboardPinballTestGame.new); + + flameTester.test('renders correctly', (game) async { + await game.ensureAdd(InitialsSubmissionFailureDisplay()); + + final component = game.firstChild(); + expect(component, isNotNull); + expect(component?.text, equals('Failure!')); + }); + }); +} diff --git a/test/game/components/backbox/displays/initials_submission_success_display_test.dart b/test/game/components/backbox/displays/initials_submission_success_display_test.dart new file mode 100644 index 00000000..1bd1fcd9 --- /dev/null +++ b/test/game/components/backbox/displays/initials_submission_success_display_test.dart @@ -0,0 +1,22 @@ +// 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/game/components/backbox/displays/initials_submission_success_display.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + group('InitialsSubmissionSuccessDisplay', () { + final flameTester = FlameTester(EmptyKeyboardPinballTestGame.new); + + flameTester.test('renders correctly', (game) async { + await game.ensureAdd(InitialsSubmissionSuccessDisplay()); + + final component = game.firstChild(); + expect(component, isNotNull); + expect(component?.text, equals('Success!')); + }); + }); +} diff --git a/test/game/components/backbox/displays/loading_display_test.dart b/test/game/components/backbox/displays/loading_display_test.dart new file mode 100644 index 00000000..a09d0d68 --- /dev/null +++ b/test/game/components/backbox/displays/loading_display_test.dart @@ -0,0 +1,54 @@ +// 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:mocktail/mocktail.dart'; +import 'package:pinball/game/components/backbox/displays/loading_display.dart'; +import 'package:pinball/l10n/l10n.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockAppLocalizations extends Mock implements AppLocalizations { + @override + String get loading => 'Loading'; +} + +void main() { + group('LoadingDisplay', () { + final flameTester = FlameTester( + () => EmptyPinballTestGame( + l10n: _MockAppLocalizations(), + ), + ); + + flameTester.test('renders correctly', (game) async { + await game.ensureAdd(LoadingDisplay()); + + final component = game.firstChild(); + expect(component, isNotNull); + expect(component?.text, equals('Loading')); + }); + + flameTester.test('use ellipses as animation', (game) async { + await game.ensureAdd(LoadingDisplay()); + + final component = game.firstChild(); + expect(component?.text, equals('Loading')); + + final timer = component?.firstChild(); + + timer?.update(1.1); + expect(component?.text, equals('Loading.')); + + timer?.update(1.1); + expect(component?.text, equals('Loading..')); + + timer?.update(1.1); + expect(component?.text, equals('Loading...')); + + timer?.update(1.1); + expect(component?.text, equals('Loading')); + }); + }); +} diff --git a/test/game/components/camera_controller_test.dart b/test/game/components/camera_controller_test.dart index 6af3f594..934f6340 100644 --- a/test/game/components/camera_controller_test.dart +++ b/test/game/components/camera_controller_test.dart @@ -24,15 +24,18 @@ void main() { test('correctly calculates the zooms', () async { expect(controller.gameFocus.zoom.toInt(), equals(12)); - expect(controller.backboardFocus.zoom.toInt(), equals(11)); + expect(controller.waitingBackboxFocus.zoom.toInt(), equals(11)); }); test('correctly sets the initial zoom and position', () async { - expect(game.camera.zoom, equals(controller.backboardFocus.zoom)); - expect(game.camera.follow, equals(controller.backboardFocus.position)); + expect(game.camera.zoom, equals(controller.waitingBackboxFocus.zoom)); + expect( + game.camera.follow, + equals(controller.waitingBackboxFocus.position), + ); }); - group('focusOnBoard', () { + group('focusOnGame', () { test('changes the zoom', () async { controller.focusOnGame(); @@ -53,22 +56,22 @@ void main() { await future; - expect(game.camera.position, Vector2(-4, -108.8)); + expect(game.camera.position, Vector2(-4, -120)); }); }); - group('focusOnBackboard', () { + group('focusOnWaitingBackbox', () { test('changes the zoom', () async { - controller.focusOnBackboard(); + controller.focusOnWaitingBackbox(); await game.ready(); final zoom = game.firstChild(); expect(zoom, isNotNull); - expect(zoom?.value, equals(controller.backboardFocus.zoom)); + expect(zoom?.value, equals(controller.waitingBackboxFocus.zoom)); }); test('moves the camera after the zoom is completed', () async { - controller.focusOnBackboard(); + controller.focusOnWaitingBackbox(); await game.ready(); final cameraZoom = game.firstChild()!; final future = cameraZoom.completed; @@ -78,7 +81,32 @@ void main() { await future; - expect(game.camera.position, Vector2(-4.5, -109.8)); + expect(game.camera.position, Vector2(-4.5, -121)); + }); + }); + + group('focusOnGameOverBackbox', () { + test('changes the zoom', () async { + controller.focusOnGameOverBackbox(); + + await game.ready(); + final zoom = game.firstChild(); + expect(zoom, isNotNull); + expect(zoom?.value, equals(controller.gameOverBackboxFocus.zoom)); + }); + + test('moves the camera after the zoom is completed', () async { + controller.focusOnGameOverBackbox(); + await game.ready(); + final cameraZoom = game.firstChild()!; + final future = cameraZoom.completed; + + game.update(10); + game.update(0); // Ensure that the component was removed + + await future; + + expect(game.camera.position, Vector2(-2.5, -117)); }); }); }); diff --git a/test/game/components/controlled_ball_test.dart b/test/game/components/controlled_ball_test.dart index 17178e87..dc142ffd 100644 --- a/test/game/components/controlled_ball_test.dart +++ b/test/game/components/controlled_ball_test.dart @@ -1,21 +1,20 @@ // ignore_for_file: cascade_invocations import 'package:bloc_test/bloc_test.dart'; -import 'package:flame/extensions.dart'; -import 'package:flame_forge2d/flame_forge2d.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/game/game.dart'; - import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../helpers/helpers.dart'; // TODO(allisonryan0002): remove once // https://github.com/flame-engine/flame/pull/1520 is merged class _WrappedBallController extends BallController { - _WrappedBallController(Ball ball, this._gameRef) : super(ball); + _WrappedBallController(Ball ball, this._gameRef) : super(ball); final PinballGame _gameRef; @@ -33,13 +32,16 @@ class _MockBall extends Mock implements Ball {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + theme.Assets.images.dash.ball.keyName, + ]; group('BallController', () { late Ball ball; late GameBloc gameBloc; setUp(() { - ball = Ball(baseColor: const Color(0xFF00FFFF)); + ball = Ball(); gameBloc = _MockGameBloc(); whenListen( gameBloc, @@ -51,6 +53,7 @@ void main() { final flameBlocTester = FlameBlocTester( gameBuilder: EmptyPinballTestGame.new, blocBuilder: () => gameBloc, + assets: assets, ); test('can be instantiated', () { @@ -68,7 +71,7 @@ void main() { await ball.add(controller); await game.ensureAdd(ball); - final otherBall = Ball(baseColor: const Color(0xFF00FFFF)); + final otherBall = Ball(); final otherController = BallController(otherBall); await otherBall.add(otherController); await game.ensureAdd(otherBall); @@ -100,11 +103,13 @@ void main() { group('turboCharge', () { setUpAll(() { registerFallbackValue(Vector2.zero()); + registerFallbackValue(Component()); }); flameBlocTester.testGameWidget( 'adds TurboChargeActivated', setUp: (game, tester) async { + await game.images.loadAll(assets); final controller = BallController(ball); await ball.add(controller); await game.ensureAdd(ball); @@ -125,7 +130,7 @@ void main() { final controller = _WrappedBallController(ball, gameRef); when(() => gameRef.read()).thenReturn(gameBloc); when(() => ball.controller).thenReturn(controller); - when(() => ball.boost(any())).thenAnswer((_) async {}); + when(() => ball.add(any())).thenAnswer((_) async {}); await controller.turboCharge(); @@ -141,29 +146,13 @@ void main() { final controller = _WrappedBallController(ball, gameRef); when(() => gameRef.read()).thenReturn(gameBloc); when(() => ball.controller).thenReturn(controller); - when(() => ball.boost(any())).thenAnswer((_) async {}); + when(() => ball.add(any())).thenAnswer((_) async {}); await controller.turboCharge(); verify(ball.resume).called(1); }, ); - - flameBlocTester.test( - 'boosts the ball', - (game) async { - final gameRef = _MockPinballGame(); - final ball = _MockControlledBall(); - final controller = _WrappedBallController(ball, gameRef); - when(() => gameRef.read()).thenReturn(gameBloc); - when(() => ball.controller).thenReturn(controller); - when(() => ball.boost(any())).thenAnswer((_) async {}); - - await controller.turboCharge(); - - verify(() => ball.boost(any())).called(1); - }, - ); }); }); } diff --git a/test/game/components/controlled_flipper_test.dart b/test/game/components/controlled_flipper_test.dart index e8b7aaf3..af262dbf 100644 --- a/test/game/components/controlled_flipper_test.dart +++ b/test/game/components/controlled_flipper_test.dart @@ -22,23 +22,19 @@ void main() { () => EmptyPinballTestGame(assets: assets), ); - final flameBlocTester = FlameBlocTester( - gameBuilder: EmptyPinballTestGame.new, - blocBuilder: () { - final bloc = _MockGameBloc(); - const state = GameState( - score: 0, - multiplier: 1, - rounds: 0, - bonusHistory: [], - ); - whenListen(bloc, Stream.value(state), initialState: state); - return bloc; - }, - assets: assets, - ); - group('FlipperController', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = _MockGameBloc(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + assets: assets, + ); + group('onKeyEvent', () { final leftKeys = UnmodifiableListView([ LogicalKeyboardKey.arrowLeft, @@ -64,6 +60,12 @@ void main() { 'moves upwards ' 'when ${event.logicalKey.keyLabel} is pressed', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ready(); await game.add(flipper); controller.onKeyEvent(event, {}); @@ -78,6 +80,14 @@ void main() { flameBlocTester.testGameWidget( 'does nothing when is game over', setUp: (game, tester) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial().copyWith( + status: GameStatus.gameOver, + ), + ); + await game.ensureAdd(flipper); controller.onKeyEvent(event, {}); }, @@ -93,6 +103,12 @@ void main() { 'moves downwards ' 'when ${event.logicalKey.keyLabel} is released', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ready(); await game.add(flipper); controller.onKeyEvent(event, {}); @@ -108,6 +124,12 @@ void main() { 'does nothing ' 'when ${event.logicalKey.keyLabel} is released', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ready(); await game.add(flipper); controller.onKeyEvent(event, {}); @@ -134,6 +156,12 @@ void main() { 'moves upwards ' 'when ${event.logicalKey.keyLabel} is pressed', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ready(); await game.add(flipper); controller.onKeyEvent(event, {}); @@ -149,6 +177,12 @@ void main() { 'moves downwards ' 'when ${event.logicalKey.keyLabel} is released', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ready(); await game.add(flipper); controller.onKeyEvent(event, {}); @@ -163,6 +197,14 @@ void main() { flameBlocTester.testGameWidget( 'does nothing when is game over', setUp: (game, tester) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial().copyWith( + status: GameStatus.gameOver, + ), + ); + await game.ensureAdd(flipper); controller.onKeyEvent(event, {}); }, diff --git a/test/game/components/controlled_plunger_test.dart b/test/game/components/controlled_plunger_test.dart index c832e24a..f91b0c37 100644 --- a/test/game/components/controlled_plunger_test.dart +++ b/test/game/components/controlled_plunger_test.dart @@ -17,22 +17,18 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); final flameTester = FlameTester(EmptyPinballTestGame.new); - final flameBlocTester = FlameBlocTester( - gameBuilder: EmptyPinballTestGame.new, - blocBuilder: () { - final bloc = _MockGameBloc(); - const state = GameState( - score: 0, - multiplier: 1, - rounds: 0, - bonusHistory: [], - ); - whenListen(bloc, Stream.value(state), initialState: state); - return bloc; - }, - ); - group('PlungerController', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = _MockGameBloc(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + ); + group('onKeyEvent', () { final downKeys = UnmodifiableListView([ LogicalKeyboardKey.arrowDown, @@ -54,6 +50,12 @@ void main() { 'moves down ' 'when ${event.logicalKey.keyLabel} is pressed', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ensureAdd(plunger); controller.onKeyEvent(event, {}); @@ -69,6 +71,12 @@ void main() { 'when ${event.logicalKey.keyLabel} is released ' 'and plunger is below its starting position', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ensureAdd(plunger); plunger.body.setTransform(Vector2(0, 1), 0); controller.onKeyEvent(event, {}); @@ -84,6 +92,12 @@ void main() { 'does not move when ${event.logicalKey.keyLabel} is released ' 'and plunger is in its starting position', (game) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + await game.ensureAdd(plunger); controller.onKeyEvent(event, {}); @@ -97,6 +111,14 @@ void main() { flameBlocTester.testGameWidget( 'does nothing when is game over', setUp: (game, tester) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial().copyWith( + status: GameStatus.gameOver, + ), + ); + await game.ensureAdd(plunger); controller.onKeyEvent(event, {}); }, diff --git a/test/game/components/dino_desert/dino_desert_test.dart b/test/game/components/dino_desert/dino_desert_test.dart index 20c9ad38..63e45e5b 100644 --- a/test/game/components/dino_desert/dino_desert_test.dart +++ b/test/game/components/dino_desert/dino_desert_test.dart @@ -2,6 +2,7 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/components/dino_desert/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -67,13 +68,13 @@ void main() { group('adds', () { flameTester.test( - 'ScoringBehavior to ChromeDino', + 'ScoringContactBehavior to ChromeDino', (game) async { await game.ensureAdd(DinoDesert()); final chromeDino = game.descendants().whereType().single; expect( - chromeDino.firstChild(), + chromeDino.firstChild(), isNotNull, ); }, 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 index f9e2988d..71b41029 100644 --- 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 @@ -10,15 +10,21 @@ import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart' import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../../../helpers/helpers.dart'; class _MockGameBloc extends Mock implements GameBloc {} void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('FlutterForestBonusBehavior', () { late GameBloc gameBloc; - final assets = [Assets.images.dash.animatronic.keyName]; + final assets = [ + Assets.images.dash.animatronic.keyName, + theme.Assets.images.dash.ball.keyName, + ]; setUp(() { gameBloc = _MockGameBloc(); @@ -32,6 +38,7 @@ void main() { final flameBlocTester = FlameBlocTester( gameBuilder: EmptyPinballTestGame.new, blocBuilder: () => gameBloc, + assets: assets, ); void _contactedBumper(DashNestBumper bumper) => diff --git a/test/game/components/flutter_forest/flutter_forest_test.dart b/test/game/components/flutter_forest/flutter_forest_test.dart index 5761a9eb..6dddcd7b 100644 --- a/test/game/components/flutter_forest/flutter_forest_test.dart +++ b/test/game/components/flutter_forest/flutter_forest_test.dart @@ -2,6 +2,7 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; @@ -73,6 +74,21 @@ void main() { ); }, ); + + flameTester.test( + 'three DashNestBumpers with BumperNoisyBehavior', + (game) async { + final flutterForest = FlutterForest(); + await game.ensureAdd(ZCanvasComponent(children: [flutterForest])); + final bumpers = game.descendants().whereType(); + for (final bumper in bumpers) { + expect( + bumper.firstChild(), + isNotNull, + ); + } + }, + ); }); }); } diff --git a/test/game/components/game_flow_controller_test.dart b/test/game/components/game_bloc_status_listener_test.dart similarity index 52% rename from test/game/components/game_flow_controller_test.dart rename to test/game/components/game_bloc_status_listener_test.dart index c85d0b52..4bb313e6 100644 --- a/test/game/components/game_flow_controller_test.dart +++ b/test/game/components/game_bloc_status_listener_test.dart @@ -5,34 +5,39 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_audio/pinball_audio.dart'; -import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_theme/pinball_theme.dart'; class _MockPinballGame extends Mock implements PinballGame {} -class _MockBackboard extends Mock implements Backboard {} +class _MockBackbox extends Mock implements Backbox {} class _MockCameraController extends Mock implements CameraController {} class _MockActiveOverlaysNotifier extends Mock implements ActiveOverlaysNotifier {} -class _MockPinballAudio extends Mock implements PinballAudio {} +class _MockPinballPlayer extends Mock implements PinballPlayer {} void main() { - group('GameFlowController', () { + group('GameBlocStatusListener', () { + setUpAll(() { + registerFallbackValue(AndroidTheme()); + }); + group('listenWhen', () { test('is true when the game over state has changed', () { final state = GameState( - score: 10, + totalScore: 0, + roundScore: 10, multiplier: 1, rounds: 0, bonusHistory: const [], + status: GameStatus.playing, ); final previous = GameState.initial(); expect( - GameFlowController(_MockPinballGame()).listenWhen(previous, state), + GameBlocStatusListener().listenWhen(previous, state), isTrue, ); }); @@ -40,69 +45,72 @@ void main() { group('onNewState', () { late PinballGame game; - late Backboard backboard; + late Backbox backbox; late CameraController cameraController; - late GameFlowController gameFlowController; - late PinballAudio pinballAudio; + late GameBlocStatusListener gameFlowController; + late PinballPlayer pinballPlayer; late ActiveOverlaysNotifier overlays; setUp(() { game = _MockPinballGame(); - backboard = _MockBackboard(); + backbox = _MockBackbox(); cameraController = _MockCameraController(); - gameFlowController = GameFlowController(game); + gameFlowController = GameBlocStatusListener(); overlays = _MockActiveOverlaysNotifier(); - pinballAudio = _MockPinballAudio(); + pinballPlayer = _MockPinballPlayer(); + + gameFlowController.mockGameRef(game); when( - () => backboard.gameOverMode( + () => backbox.requestInitials( score: any(named: 'score'), - characterIconPath: any(named: 'characterIconPath'), - onSubmit: any(named: 'onSubmit'), + character: any(named: 'character'), ), ).thenAnswer((_) async {}); - when(backboard.waitingMode).thenAnswer((_) async {}); - when(cameraController.focusOnBackboard).thenAnswer((_) async {}); + when(cameraController.focusOnWaitingBackbox).thenAnswer((_) async {}); when(cameraController.focusOnGame).thenAnswer((_) async {}); when(() => overlays.remove(any())).thenAnswer((_) => true); - when(game.firstChild).thenReturn(backboard); + when(() => game.descendants().whereType()) + .thenReturn([backbox]); when(game.firstChild).thenReturn(cameraController); when(() => game.overlays).thenReturn(overlays); when(() => game.characterTheme).thenReturn(DashTheme()); - when(() => game.audio).thenReturn(pinballAudio); + when(() => game.player).thenReturn(pinballPlayer); }); test( - 'changes the backboard and camera correctly when it is a game over', + 'changes the backbox display and camera correctly ' + 'when the game is over', () { - gameFlowController.onNewState( - GameState( - score: 10, - multiplier: 1, - rounds: 0, - bonusHistory: const [], - ), + final state = GameState( + totalScore: 0, + roundScore: 10, + multiplier: 1, + rounds: 0, + bonusHistory: const [], + status: GameStatus.gameOver, ); + gameFlowController.onNewState(state); verify( - () => backboard.gameOverMode( - score: 0, - characterIconPath: any(named: 'characterIconPath'), - onSubmit: any(named: 'onSubmit'), + () => backbox.requestInitials( + score: any(named: 'score'), + character: any(named: 'character'), ), ).called(1); - verify(cameraController.focusOnBackboard).called(1); + verify(cameraController.focusOnGameOverBackbox).called(1); }, ); test( - 'changes the backboard and camera correctly when it is not a game over', + 'changes the backbox and camera correctly when it is not a game over', () { - gameFlowController.onNewState(GameState.initial()); + gameFlowController.onNewState( + GameState.initial().copyWith(status: GameStatus.playing), + ); - verify(backboard.waitingMode).called(1); verify(cameraController.focusOnGame).called(1); verify(() => overlays.remove(PinballGame.playButtonOverlay)) .called(1); @@ -112,9 +120,12 @@ void main() { test( 'plays the background music on start', () { - gameFlowController.onNewState(GameState.initial()); + gameFlowController.onNewState( + GameState.initial().copyWith(status: GameStatus.playing), + ); - verify(pinballAudio.backgroundMusic).called(1); + verify(() => pinballPlayer.play(PinballAudio.backgroundMusic)) + .called(1); }, ); }); diff --git a/test/game/components/golden/backbox.png b/test/game/components/golden/backbox.png new file mode 100644 index 00000000..962573ab Binary files /dev/null and b/test/game/components/golden/backbox.png differ diff --git a/test/game/components/launcher_test.dart b/test/game/components/launcher_test.dart new file mode 100644 index 00000000..c76e6b7e --- /dev/null +++ b/test/game/components/launcher_test.dart @@ -0,0 +1,85 @@ +// 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.launchRamp.ramp.keyName, + Assets.images.launchRamp.backgroundRailing.keyName, + Assets.images.launchRamp.foregroundRailing.keyName, + Assets.images.flapper.backSupport.keyName, + Assets.images.flapper.frontSupport.keyName, + Assets.images.flapper.flap.keyName, + Assets.images.plunger.plunger.keyName, + Assets.images.plunger.rocket.keyName, + ]; + final flameTester = FlameTester( + () => EmptyPinballTestGame(assets: assets), + ); + + group('Launcher', () { + flameTester.test( + 'loads correctly', + (game) async { + final launcher = Launcher(); + await game.ensureAdd(launcher); + + expect(game.contains(launcher), isTrue); + }, + ); + + group('loads', () { + flameTester.test( + 'a LaunchRamp', + (game) async { + final launcher = Launcher(); + await game.ensureAdd(launcher); + + final descendantsQuery = + launcher.descendants().whereType(); + expect(descendantsQuery.length, equals(1)); + }, + ); + + flameTester.test( + 'a Flapper', + (game) async { + final launcher = Launcher(); + await game.ensureAdd(launcher); + + final descendantsQuery = launcher.descendants().whereType(); + expect(descendantsQuery.length, equals(1)); + }, + ); + + flameTester.test( + 'a Plunger', + (game) async { + final launcher = Launcher(); + await game.ensureAdd(launcher); + + final descendantsQuery = launcher.descendants().whereType(); + expect(descendantsQuery.length, equals(1)); + }, + ); + + flameTester.test( + 'a RocketSpriteComponent', + (game) async { + final launcher = Launcher(); + await game.ensureAdd(launcher); + + final descendantsQuery = + launcher.descendants().whereType(); + expect(descendantsQuery.length, equals(1)); + }, + ); + }); + }); +} diff --git a/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart b/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart index 5f8b1400..03c50041 100644 --- a/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart +++ b/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart @@ -74,10 +74,12 @@ void main() { test('is false when the bonusHistory state is the same', () { final previous = GameState.initial(); final state = GameState( - score: 10, + totalScore: 0, + roundScore: 10, multiplier: 1, rounds: 0, bonusHistory: const [], + status: GameStatus.playing, ); expect( diff --git a/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart b/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart index 40a952f1..ef39aad2 100644 --- a/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart +++ b/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart @@ -56,9 +56,11 @@ void main() { group('listenWhen', () { test('is true when the multiplier has changed', () { final state = GameState( - score: 10, + totalScore: 0, + roundScore: 10, multiplier: 2, rounds: 0, + status: GameStatus.playing, bonusHistory: const [], ); @@ -71,9 +73,11 @@ void main() { test('is false when the multiplier state is the same', () { final state = GameState( - score: 10, + totalScore: 0, + roundScore: 10, multiplier: 1, rounds: 0, + status: GameStatus.playing, bonusHistory: const [], ); diff --git a/test/game/components/scoring_behavior_test.dart b/test/game/components/scoring_behavior_test.dart deleted file mode 100644 index 485183aa..00000000 --- a/test/game/components/scoring_behavior_test.dart +++ /dev/null @@ -1,133 +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_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_audio/pinball_audio.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -import '../../helpers/helpers.dart'; - -class _TestBodyComponent extends BodyComponent { - @override - Body createBody() => world.createBody(BodyDef()); -} - -class _MockPinballAudio extends Mock implements PinballAudio {} - -class _MockBall extends Mock implements Ball {} - -class _MockBody extends Mock implements Body {} - -class _MockGameBloc extends Mock implements GameBloc {} - -class _MockContact extends Mock implements Contact {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final assets = [ - Assets.images.score.fiveThousand.keyName, - Assets.images.score.twentyThousand.keyName, - Assets.images.score.twoHundredThousand.keyName, - Assets.images.score.oneMillion.keyName, - ]; - - 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; - }, - assets: assets, - ); - - flameBlocTester.testGameWidget( - 'emits Scored event with points', - setUp: (game, tester) async { - const points = Points.oneMillion; - final scoringBehavior = ScoringBehavior(points: points); - await parent.add(scoringBehavior); - final canvas = ZCanvasComponent(children: [parent]); - await game.ensureAdd(canvas); - - scoringBehavior.beginContact(ball, _MockContact()); - - verify( - () => bloc.add( - Scored(points: points.value), - ), - ).called(1); - }, - ); - - flameBlocTester.testGameWidget( - 'plays score sound', - setUp: (game, tester) async { - final scoringBehavior = ScoringBehavior(points: Points.oneMillion); - await parent.add(scoringBehavior); - final canvas = ZCanvasComponent(children: [parent]); - await game.ensureAdd(canvas); - - scoringBehavior.beginContact(ball, _MockContact()); - - verify(audio.score).called(1); - }, - ); - - flameBlocTester.testGameWidget( - "adds a ScoreComponent at Ball's position with points", - setUp: (game, tester) async { - const points = Points.oneMillion; - final scoringBehavior = ScoringBehavior(points: points); - await parent.add(scoringBehavior); - final canvas = ZCanvasComponent(children: [parent]); - await game.ensureAdd(canvas); - - scoringBehavior.beginContact(ball, _MockContact()); - await game.ready(); - - final scoreText = game.descendants().whereType(); - expect(scoreText.length, equals(1)); - expect( - scoreText.first.points, - equals(points), - ); - expect( - scoreText.first.position, - equals(ball.body.position), - ); - }, - ); - }); - }); -} diff --git a/test/game/components/sparky_scorch_test.dart b/test/game/components/sparky_scorch_test.dart index 7d9c8c77..5df250dd 100644 --- a/test/game/components/sparky_scorch_test.dart +++ b/test/game/components/sparky_scorch_test.dart @@ -4,6 +4,7 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -74,6 +75,20 @@ void main() { ); }, ); + + flameTester.test( + 'three SparkyBumpers with BumperNoisyBehavior', + (game) async { + await game.ensureAdd(SparkyScorch()); + final bumpers = game.descendants().whereType(); + for (final bumper in bumpers) { + expect( + bumper.firstChild(), + isNotNull, + ); + } + }, + ); }); }); diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 884037f4..cf70ad43 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -1,6 +1,9 @@ // ignore_for_file: cascade_invocations +import 'dart:ui'; + import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.dart'; import 'package:flame/input.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/gestures.dart'; @@ -8,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_theme/pinball_theme.dart' as theme; import '../helpers/helpers.dart'; @@ -25,6 +29,12 @@ class _MockTapUpDetails extends Mock implements TapUpDetails {} class _MockTapUpInfo extends Mock implements TapUpInfo {} +class _MockDragStartInfo extends Mock implements DragStartInfo {} + +class _MockDragUpdateInfo extends Mock implements DragUpdateInfo {} + +class _MockDragEndInfo extends Mock implements DragEndInfo {} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); final assets = [ @@ -34,11 +44,13 @@ void main() { Assets.images.android.bumper.b.dimmed.keyName, Assets.images.android.bumper.cow.lit.keyName, Assets.images.android.bumper.cow.dimmed.keyName, - Assets.images.backboard.backboardScores.keyName, - Assets.images.backboard.backboardGameOver.keyName, - Assets.images.backboard.display.keyName, + Assets.images.backbox.marquee.keyName, + Assets.images.backbox.displayDivider.keyName, Assets.images.boardBackground.keyName, - Assets.images.ball.ball.keyName, + theme.Assets.images.android.ball.keyName, + theme.Assets.images.dash.ball.keyName, + theme.Assets.images.dino.ball.keyName, + theme.Assets.images.sparky.ball.keyName, Assets.images.ball.flameEffect.keyName, Assets.images.baseboard.left.keyName, Assets.images.baseboard.right.keyName, @@ -124,6 +136,13 @@ void main() { Assets.images.sparky.bumper.b.dimmed.keyName, Assets.images.sparky.bumper.c.lit.keyName, Assets.images.sparky.bumper.c.dimmed.keyName, + Assets.images.flapper.flap.keyName, + Assets.images.flapper.backSupport.keyName, + Assets.images.flapper.frontSupport.keyName, + Assets.images.skillShot.decal.keyName, + Assets.images.skillShot.pin.keyName, + Assets.images.skillShot.lit.keyName, + Assets.images.skillShot.dimmed.keyName, ]; late GameBloc gameBloc; @@ -137,19 +156,16 @@ void main() { ); }); - final flameTester = FlameTester( - () => PinballTestGame(assets: assets), - ); - final debugModeFlameTester = FlameTester( - () => DebugPinballTestGame(assets: assets), - ); + group('PinballGame', () { + final flameTester = FlameTester( + () => PinballTestGame(assets: assets), + ); - final flameBlocTester = FlameBlocTester( - gameBuilder: () => PinballTestGame(assets: assets), - blocBuilder: () => gameBloc, - ); + final flameBlocTester = FlameBlocTester( + gameBuilder: () => PinballTestGame(assets: assets), + blocBuilder: () => gameBloc, + ); - group('PinballGame', () { group('components', () { // TODO(alestiago): tests that Blueprints get added once the Blueprint // class is removed. @@ -186,13 +202,16 @@ void main() { }, ); - flameBlocTester.test('has one FlutterForest', (game) async { - await game.ready(); - expect( - game.descendants().whereType().length, - equals(1), - ); - }); + flameBlocTester.test( + 'has one FlutterForest', + (game) async { + await game.ready(); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); flameBlocTester.test( 'has only one Multiballs', @@ -217,6 +236,43 @@ void main() { }, ); + flameBlocTester.test('one SkillShot', (game) async { + await game.ready(); + expect( + game.descendants().whereType().length, + equals(1), + ); + }); + + flameBlocTester.testGameWidget( + 'paints sprites with FilterQuality.medium', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ready(); + + final descendants = game.descendants(); + final components = [ + ...descendants.whereType(), + ...descendants.whereType(), + ]; + expect(components, isNotEmpty); + expect( + components.whereType().length, + equals(components.length), + ); + + await tester.pump(); + + for (final component in components) { + if (component is! HasPaint) return; + expect( + component.paint.filterQuality, + equals(FilterQuality.medium), + ); + } + }, + ); + group('controller', () { group('listenWhen', () { flameTester.testGameWidget( @@ -225,7 +281,7 @@ void main() { // 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.isGameOver).thenReturn(false); + when(() => newState.status).thenReturn(GameStatus.playing); game.descendants().whereType().forEach( (ball) => ball.controller.lost(), ); @@ -242,7 +298,9 @@ void main() { "doesn't listen when some balls are left", (game) async { final newState = _MockGameState(); - when(() => newState.isGameOver).thenReturn(false); + when(() => newState.status).thenReturn(GameStatus.playing); + + await game.ready(); expect( game.descendants().whereType().length, @@ -261,7 +319,7 @@ void main() { // 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.isGameOver).thenReturn(true); + when(() => newState.status).thenReturn(GameStatus.gameOver); game.descendants().whereType().forEach( (ball) => ball.controller.lost(), ); @@ -279,28 +337,25 @@ void main() { ); }); - group( - 'onNewState', - () { - flameTester.test( - 'spawns a ball', - (game) async { - final previousBalls = - game.descendants().whereType().toList(); - - game.controller.onNewState(_MockGameState()); - await game.ready(); - final currentBalls = - game.descendants().whereType().toList(); - - expect( - currentBalls.length, - equals(previousBalls.length + 1), - ); - }, - ); - }, - ); + group('onNewState', () { + flameTester.test( + 'spawns a ball', + (game) async { + final previousBalls = + game.descendants().whereType().toList(); + + game.controller.onNewState(_MockGameState()); + await game.ready(); + final currentBalls = + game.descendants().whereType().toList(); + + expect( + currentBalls.length, + equals(previousBalls.length + 1), + ); + }, + ); + }); }); }); @@ -323,7 +378,7 @@ void main() { (flipper) => flipper.side == BoardSide.left, ); - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); expect(flippers.first.body.linearVelocity.y, isNegative); }); @@ -346,7 +401,7 @@ void main() { (flipper) => flipper.side == BoardSide.right, ); - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); expect(flippers.first.body.linearVelocity.y, isNegative); }); @@ -369,14 +424,14 @@ void main() { (flipper) => flipper.side == BoardSide.left, ); - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); expect(flippers.first.body.linearVelocity.y, isNegative); final tapUpEvent = _MockTapUpInfo(); when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); - game.onTapUp(tapUpEvent); + game.onTapUp(0, tapUpEvent); await game.ready(); expect(flippers.first.body.linearVelocity.y, isPositive); @@ -400,14 +455,59 @@ void main() { (flipper) => flipper.side == BoardSide.left, ); - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); expect(flippers.first.body.linearVelocity.y, isNegative); - game.onTapCancel(); + game.onTapCancel(0); expect(flippers.first.body.linearVelocity.y, isPositive); }); + + flameTester.test( + 'multiple touches control both flippers', + (game) async { + await game.ready(); + + final raw = _MockTapDownDetails(); + when(() => raw.kind).thenReturn(PointerDeviceKind.touch); + + final leftEventPosition = _MockEventPosition(); + when(() => leftEventPosition.game).thenReturn(Vector2.zero()); + when(() => leftEventPosition.widget).thenReturn(Vector2.zero()); + + final rightEventPosition = _MockEventPosition(); + when(() => rightEventPosition.game).thenReturn(Vector2.zero()); + when(() => rightEventPosition.widget).thenReturn(game.canvasSize); + + final leftTapDownEvent = _MockTapDownInfo(); + when(() => leftTapDownEvent.eventPosition) + .thenReturn(leftEventPosition); + when(() => leftTapDownEvent.raw).thenReturn(raw); + + final rightTapDownEvent = _MockTapDownInfo(); + when(() => rightTapDownEvent.eventPosition) + .thenReturn(rightEventPosition); + when(() => rightTapDownEvent.raw).thenReturn(raw); + + final flippers = game.descendants().whereType(); + final rightFlipper = flippers.elementAt(0); + final leftFlipper = flippers.elementAt(1); + + game.onTapDown(0, leftTapDownEvent); + game.onTapDown(1, rightTapDownEvent); + + expect(leftFlipper.body.linearVelocity.y, isNegative); + expect(leftFlipper.side, equals(BoardSide.left)); + expect(rightFlipper.body.linearVelocity.y, isNegative); + expect(rightFlipper.side, equals(BoardSide.right)); + + expect( + game.focusedBoardSide, + equals({0: BoardSide.left, 1: BoardSide.right}), + ); + }, + ); }); group('plunger control', () { @@ -426,7 +526,7 @@ void main() { final plunger = game.descendants().whereType().first; - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); game.update(1); @@ -436,6 +536,11 @@ void main() { }); group('DebugPinballGame', () { + final debugAssets = [Assets.images.ball.flameEffect.keyName, ...assets]; + final debugModeFlameTester = FlameTester( + () => DebugPinballTestGame(assets: debugAssets), + ); + debugModeFlameTester.test( 'adds a ball on tap up', (game) async { @@ -449,10 +554,77 @@ void main() { when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); when(() => tapUpEvent.raw).thenReturn(raw); + await game.ready(); + final previousBalls = + game.descendants().whereType().toList(); + + game.onTapUp(0, tapUpEvent); + await game.ready(); + + final currentBalls = + game.descendants().whereType().toList(); + + expect( + currentBalls.length, + equals(previousBalls.length + 1), + ); + }, + ); + + debugModeFlameTester.test( + 'set lineStart on pan start', + (game) async { + final startPosition = Vector2.all(10); + final eventPosition = _MockEventPosition(); + when(() => eventPosition.game).thenReturn(startPosition); + + final dragStartInfo = _MockDragStartInfo(); + when(() => dragStartInfo.eventPosition).thenReturn(eventPosition); + + game.onPanStart(dragStartInfo); + await game.ready(); + + expect( + game.lineStart, + equals(startPosition), + ); + }, + ); + + debugModeFlameTester.test( + 'set lineEnd on pan update', + (game) async { + final endPosition = Vector2.all(10); + final eventPosition = _MockEventPosition(); + when(() => eventPosition.game).thenReturn(endPosition); + + final dragUpdateInfo = _MockDragUpdateInfo(); + when(() => dragUpdateInfo.eventPosition).thenReturn(eventPosition); + + game.onPanUpdate(dragUpdateInfo); + await game.ready(); + + expect( + game.lineEnd, + equals(endPosition), + ); + }, + ); + + debugModeFlameTester.test( + 'launch ball on pan end', + (game) async { + final startPosition = Vector2.zero(); + final endPosition = Vector2.all(10); + + game.lineStart = startPosition; + game.lineEnd = endPosition; + + await game.ready(); final previousBalls = game.descendants().whereType().toList(); - game.onTapUp(tapUpEvent); + game.onPanEnd(_MockDragEndInfo()); await game.ready(); expect( diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 0ed6e744..90d1b194 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -200,12 +200,11 @@ void main() { find.byWidgetPredicate((w) => w is GameWidget), findsOneWidget, ); - // TODO(arturplaczek): add Visibility to GameHud based on StartGameBloc - // status - // expect( - // find.byType(GameHud), - // findsNothing, - // ); + + expect( + find.byType(GameHud), + findsNothing, + ); }); testWidgets('renders a hud on play state', (tester) async { diff --git a/test/game/view/widgets/bonus_animation_test.dart b/test/game/view/widgets/bonus_animation_test.dart index 2284ca8d..52c1b3d8 100644 --- a/test/game/view/widgets/bonus_animation_test.dart +++ b/test/game/view/widgets/bonus_animation_test.dart @@ -1,9 +1,5 @@ // ignore_for_file: invalid_use_of_protected_member -import 'dart:typed_data'; - -import 'package:flame/assets.dart'; -import 'package:flame/flame.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -13,8 +9,6 @@ import 'package:pinball_flame/pinball_flame.dart'; import '../../../helpers/helpers.dart'; -class _MockImages extends Mock implements Images {} - class _MockCallback extends Mock { void call(); } @@ -24,13 +18,7 @@ void main() { const animationDuration = 6; setUp(() async { - // 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; + await mockFlameImages(); }); group('loads SpriteAnimationWidget correctly for', () { diff --git a/test/game/view/widgets/game_hud_test.dart b/test/game/view/widgets/game_hud_test.dart index f8be70c2..f4054146 100644 --- a/test/game/view/widgets/game_hud_test.dart +++ b/test/game/view/widgets/game_hud_test.dart @@ -1,11 +1,8 @@ // ignore_for_file: prefer_const_constructors import 'dart:async'; -import 'dart:typed_data'; 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'; @@ -18,8 +15,6 @@ import 'package:pinball_ui/pinball_ui.dart'; import '../../../helpers/helpers.dart'; -class _MockImages extends Mock implements Images {} - class _MockGameBloc extends Mock implements GameBloc {} void main() { @@ -27,22 +22,18 @@ void main() { late GameBloc gameBloc; const initialState = GameState( - score: 1000, + totalScore: 0, + roundScore: 1000, multiplier: 1, rounds: 1, bonusHistory: [], + status: GameStatus.playing, ); setUp(() async { - gameBloc = _MockGameBloc(); + await mockFlameImages(); - // 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; + gameBloc = _MockGameBloc(); whenListen( gameBloc, @@ -81,7 +72,10 @@ void main() { gameBloc: gameBloc, ); - expect(find.text(initialState.score.formatScore()), findsOneWidget); + expect( + find.text(initialState.roundScore.formatScore()), + findsOneWidget, + ); }, ); diff --git a/test/game/view/widgets/play_button_overlay_test.dart b/test/game/view/widgets/play_button_overlay_test.dart index 1d7070e0..f10c5f5b 100644 --- a/test/game/view/widgets/play_button_overlay_test.dart +++ b/test/game/view/widgets/play_button_overlay_test.dart @@ -1,69 +1,45 @@ import 'package:bloc_test/bloc_test.dart'; -import 'package:flame/flame.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball/select_character/select_character.dart'; -import 'package:pinball_theme/pinball_theme.dart'; +import 'package:pinball/start_game/bloc/start_game_bloc.dart'; import '../../../helpers/helpers.dart'; -class _MockPinballGame extends Mock implements PinballGame {} - -class _MockGameFlowController extends Mock implements GameFlowController {} - -class _MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {} +class _MockStartGameBloc extends Mock implements StartGameBloc {} void main() { group('PlayButtonOverlay', () { - late PinballGame game; - late GameFlowController gameFlowController; - late CharacterThemeCubit characterThemeCubit; + late StartGameBloc startGameBloc; setUp(() async { - Flame.images.prefix = ''; - await Flame.images.load(const DashTheme().animation.keyName); - await Flame.images.load(const AndroidTheme().animation.keyName); - await Flame.images.load(const DinoTheme().animation.keyName); - await Flame.images.load(const SparkyTheme().animation.keyName); - game = _MockPinballGame(); - gameFlowController = _MockGameFlowController(); - characterThemeCubit = _MockCharacterThemeCubit(); + await mockFlameImages(); + startGameBloc = _MockStartGameBloc(); + whenListen( - characterThemeCubit, - const Stream.empty(), - initialState: const CharacterThemeState.initial(), + startGameBloc, + Stream.value(const StartGameState.initial()), + initialState: const StartGameState.initial(), ); - when(() => characterThemeCubit.state) - .thenReturn(const CharacterThemeState.initial()); - when(() => game.gameFlowController).thenReturn(gameFlowController); - when(gameFlowController.start).thenAnswer((_) {}); }); testWidgets('renders correctly', (tester) async { - await tester.pumpApp(PlayButtonOverlay(game: game)); - expect(find.text('Play'), findsOneWidget); - }); + await tester.pumpApp(const PlayButtonOverlay()); - testWidgets('calls gameFlowController.start when tapped', (tester) async { - await tester.pumpApp( - PlayButtonOverlay(game: game), - characterThemeCubit: characterThemeCubit, - ); - await tester.tap(find.text('Play')); - await tester.pump(); - verify(gameFlowController.start).called(1); + expect(find.text('Play'), findsOneWidget); }); - testWidgets('displays CharacterSelectionDialog when tapped', + testWidgets('adds PlayTapped event to StartGameBloc when taped', (tester) async { await tester.pumpApp( - PlayButtonOverlay(game: game), - characterThemeCubit: characterThemeCubit, + const PlayButtonOverlay(), + startGameBloc: startGameBloc, ); + await tester.tap(find.text('Play')); await tester.pump(); - expect(find.byType(CharacterSelectionDialog), findsOneWidget); + + verify(() => startGameBloc.add(const PlayTapped())).called(1); }); }); } diff --git a/test/game/view/widgets/round_count_display_test.dart b/test/game/view/widgets/round_count_display_test.dart index e3a4b887..7078df77 100644 --- a/test/game/view/widgets/round_count_display_test.dart +++ b/test/game/view/widgets/round_count_display_test.dart @@ -13,10 +13,12 @@ void main() { group('RoundCountDisplay renders', () { late GameBloc gameBloc; const initialState = GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: [], + status: GameStatus.playing, ); setUp(() { diff --git a/test/game/view/widgets/score_view_test.dart b/test/game/view/widgets/score_view_test.dart index 695dc6e1..18e94c09 100644 --- a/test/game/view/widgets/score_view_test.dart +++ b/test/game/view/widgets/score_view_test.dart @@ -15,12 +15,15 @@ class _MockGameBloc extends Mock implements GameBloc {} void main() { late GameBloc gameBloc; late StreamController stateController; - const score = 123456789; + const totalScore = 123456789; + const roundScore = 1234; const initialState = GameState( - score: score, + totalScore: totalScore, + roundScore: roundScore, multiplier: 1, rounds: 1, bonusHistory: [], + status: GameStatus.playing, ); setUp(() { @@ -42,16 +45,17 @@ void main() { ); await tester.pump(); - expect(find.text(score.formatScore()), findsOneWidget); + expect( + find.text(initialState.displayScore.formatScore()), + findsOneWidget, + ); }); testWidgets('renders game over', (tester) async { final l10n = await AppLocalizations.delegate.load(const Locale('en')); stateController.add( - initialState.copyWith( - rounds: 0, - ), + initialState.copyWith(status: GameStatus.gameOver), ); await tester.pumpApp( @@ -69,17 +73,23 @@ void main() { gameBloc: gameBloc, ); - expect(find.text(score.formatScore()), findsOneWidget); + expect( + find.text(initialState.displayScore.formatScore()), + findsOneWidget, + ); final newState = initialState.copyWith( - score: 987654321, + roundScore: 5678, ); stateController.add(newState); await tester.pump(); - expect(find.text(newState.score.formatScore()), findsOneWidget); + expect( + find.text(newState.displayScore.formatScore()), + findsOneWidget, + ); }); }); } diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index d782ede4..706733a1 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -5,70 +5,3 @@ 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/helpers.dart b/test/helpers/helpers.dart index febf8d36..6621abcc 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -2,6 +2,7 @@ export 'builders.dart'; export 'fakes.dart'; export 'forge2d.dart'; export 'key_testers.dart'; +export 'mock_flame_images.dart'; export 'pump_app.dart'; export 'test_games.dart'; export 'text_span.dart'; diff --git a/test/helpers/mock_flame_images.dart b/test/helpers/mock_flame_images.dart new file mode 100644 index 00000000..48e4d40e --- /dev/null +++ b/test/helpers/mock_flame_images.dart @@ -0,0 +1,92 @@ +import 'dart:typed_data'; + +import 'package:flame/assets.dart'; +import 'package:flame/flame.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockImages extends Mock implements Images {} + +/// {@template mock_flame_images} +/// Mock for flame images instance. +/// +/// Using real images blocks the tests, for this reason we need fake image +/// everywhere we use [Images.fromCache] or [Images.load]. +/// {@endtemplate} +// TODO(arturplaczek): need to find for a better solution for loading image +// or use original images. +Future mockFlameImages() async { + 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; +} + +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/pump_app.dart b/test/helpers/pump_app.dart index a7d7ae67..45929978 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -24,12 +24,12 @@ class _MockGameBloc extends Mock implements GameBloc {} class _MockStartGameBloc extends Mock implements StartGameBloc {} -class _MockPinballAudio extends Mock implements PinballAudio {} +class _MockPinballPlayer extends Mock implements PinballPlayer {} -PinballAudio _buildDefaultPinballAudio() { - final audio = _MockPinballAudio(); - when(audio.load).thenAnswer((_) => Future.value()); - return audio; +PinballPlayer _buildDefaultPinballPlayer() { + final player = _MockPinballPlayer(); + when(player.load).thenAnswer((_) => [Future.value()]); + return player; } AssetsManagerCubit _buildDefaultAssetsManagerCubit() { @@ -55,7 +55,7 @@ extension PumpApp on WidgetTester { AssetsManagerCubit? assetsManagerCubit, CharacterThemeCubit? characterThemeCubit, LeaderboardRepository? leaderboardRepository, - PinballAudio? pinballAudio, + PinballPlayer? pinballPlayer, }) { return runAsync(() { return pumpWidget( @@ -65,7 +65,7 @@ extension PumpApp on WidgetTester { value: leaderboardRepository ?? _MockLeaderboardRepository(), ), RepositoryProvider.value( - value: pinballAudio ?? _buildDefaultPinballAudio(), + value: pinballPlayer ?? _buildDefaultPinballPlayer(), ), ], child: MultiBlocProvider( diff --git a/test/helpers/test_games.dart b/test/helpers/test_games.dart index 5e67532a..220693c3 100644 --- a/test/helpers/test_games.dart +++ b/test/helpers/test_games.dart @@ -2,14 +2,22 @@ import 'dart:async'; +import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_theme/pinball_theme.dart'; -class _MockPinballAudio extends Mock implements PinballAudio {} +class _MockPinballPlayer extends Mock implements PinballPlayer {} + +class _MockAppLocalizations extends Mock implements AppLocalizations {} + +class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { +} class TestGame extends Forge2DGame with FlameBloc { TestGame() { @@ -20,12 +28,17 @@ class TestGame extends Forge2DGame with FlameBloc { class PinballTestGame extends PinballGame { PinballTestGame({ List? assets, - PinballAudio? audio, + PinballPlayer? player, + LeaderboardRepository? leaderboardRepository, CharacterTheme? theme, + AppLocalizations? l10n, }) : _assets = assets, super( - audio: audio ?? _MockPinballAudio(), + player: player ?? _MockPinballPlayer(), + leaderboardRepository: + leaderboardRepository ?? _MockLeaderboardRepository(), characterTheme: theme ?? const DashTheme(), + l10n: l10n ?? _MockAppLocalizations(), ); final List? _assets; @@ -41,12 +54,17 @@ class PinballTestGame extends PinballGame { class DebugPinballTestGame extends DebugPinballGame { DebugPinballTestGame({ List? assets, - PinballAudio? audio, + PinballPlayer? player, + LeaderboardRepository? leaderboardRepository, CharacterTheme? theme, + AppLocalizations? l10n, }) : _assets = assets, super( - audio: audio ?? _MockPinballAudio(), + player: player ?? _MockPinballPlayer(), + leaderboardRepository: + leaderboardRepository ?? _MockLeaderboardRepository(), characterTheme: theme ?? const DashTheme(), + l10n: l10n ?? _MockAppLocalizations(), ); final List? _assets; @@ -63,12 +81,36 @@ class DebugPinballTestGame extends DebugPinballGame { class EmptyPinballTestGame extends PinballTestGame { EmptyPinballTestGame({ List? assets, - PinballAudio? audio, + PinballPlayer? player, + CharacterTheme? theme, + AppLocalizations? l10n, + }) : super( + assets: assets, + player: player, + theme: theme, + l10n: l10n ?? _MockAppLocalizations(), + ); + + @override + Future onLoad() async { + if (_assets != null) { + await images.loadAll(_assets!); + } + } +} + +class EmptyKeyboardPinballTestGame extends PinballTestGame + with HasKeyboardHandlerComponents { + EmptyKeyboardPinballTestGame({ + List? assets, + PinballPlayer? player, CharacterTheme? theme, + AppLocalizations? l10n, }) : super( assets: assets, - audio: audio, + player: player, theme: theme, + l10n: l10n ?? _MockAppLocalizations(), ); @override diff --git a/test/how_to_play/how_to_play_dialog_test.dart b/test/how_to_play/how_to_play_dialog_test.dart index 232aa1d5..c6e60d73 100644 --- a/test/how_to_play/how_to_play_dialog_test.dart +++ b/test/how_to_play/how_to_play_dialog_test.dart @@ -3,13 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/how_to_play/how_to_play.dart'; import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball_audio/pinball_audio.dart'; import 'package:platform_helper/platform_helper.dart'; import '../helpers/helpers.dart'; -class _MockPinballAudio extends Mock implements PinballAudio {} - class _MockPlatformHelper extends Mock implements PlatformHelper {} void main() { @@ -25,7 +22,11 @@ void main() { testWidgets( 'can be instantiated without passing in a platform helper', (tester) async { - await tester.pumpApp(HowToPlayDialog()); + await tester.pumpApp( + HowToPlayDialog( + onDismissCallback: () {}, + ), + ); expect(find.byType(HowToPlayDialog), findsOneWidget); }, ); @@ -35,6 +36,7 @@ void main() { await tester.pumpApp( HowToPlayDialog( platformHelper: platformHelper, + onDismissCallback: () {}, ), ); expect(find.text(l10n.howToPlay), findsOneWidget); @@ -49,6 +51,7 @@ void main() { await tester.pumpApp( HowToPlayDialog( platformHelper: platformHelper, + onDismissCallback: () {}, ), ); expect(find.text(l10n.howToPlay), findsOneWidget); @@ -62,7 +65,12 @@ void main() { Builder( builder: (context) { return TextButton( - onPressed: () => showHowToPlayDialog(context), + onPressed: () => showDialog( + context: context, + builder: (_) => HowToPlayDialog( + onDismissCallback: () {}, + ), + ), child: const Text('test'), ); }, @@ -82,7 +90,12 @@ void main() { Builder( builder: (context) { return TextButton( - onPressed: () => showHowToPlayDialog(context), + onPressed: () => showDialog( + context: context, + builder: (_) => HowToPlayDialog( + onDismissCallback: () {}, + ), + ), child: const Text('test'), ); }, @@ -96,30 +109,5 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(HowToPlayDialog), findsNothing); }); - - testWidgets( - 'plays the I/O Pinball voice over audio on dismiss', - (tester) async { - final audio = _MockPinballAudio(); - await tester.pumpApp( - Builder( - builder: (context) { - return TextButton( - onPressed: () => showHowToPlayDialog(context), - child: const Text('test'), - ); - }, - ), - pinballAudio: audio, - ); - expect(find.byType(HowToPlayDialog), findsNothing); - await tester.tap(find.text('test')); - await tester.pumpAndSettle(); - - await tester.tapAt(Offset.zero); - await tester.pumpAndSettle(); - verify(audio.ioPinballVoiceOver).called(1); - }, - ); }); } diff --git a/test/leaderboard/models/leader_board_entry_test.dart b/test/leaderboard/models/leader_board_entry_test.dart new file mode 100644 index 00000000..aa0e10a7 --- /dev/null +++ b/test/leaderboard/models/leader_board_entry_test.dart @@ -0,0 +1,42 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/leaderboard/models/leader_board_entry.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('LeaderboardEntry', () { + group('toEntry', () { + test('returns the correct from a to entry data', () { + expect( + LeaderboardEntryData.empty.toEntry(1), + LeaderboardEntry( + rank: '1', + playerInitials: '', + score: 0, + character: CharacterType.dash.toTheme.leaderboardIcon, + ), + ); + }); + }); + + group('CharacterType', () { + test('toTheme returns the correct theme', () { + expect(CharacterType.dash.toTheme, equals(DashTheme())); + expect(CharacterType.sparky.toTheme, equals(SparkyTheme())); + expect(CharacterType.android.toTheme, equals(AndroidTheme())); + expect(CharacterType.dino.toTheme, equals(DinoTheme())); + }); + }); + + group('CharacterTheme', () { + test('toType returns the correct type', () { + expect(DashTheme().toType, equals(CharacterType.dash)); + expect(SparkyTheme().toType, equals(CharacterType.sparky)); + expect(AndroidTheme().toType, equals(CharacterType.android)); + expect(DinoTheme().toType, equals(CharacterType.dino)); + }); + }); + }); +} diff --git a/test/select_character/view/character_selection_page_test.dart b/test/select_character/view/character_selection_page_test.dart index 28033030..a9c0c7ef 100644 --- a/test/select_character/view/character_selection_page_test.dart +++ b/test/select_character/view/character_selection_page_test.dart @@ -1,10 +1,9 @@ import 'package:bloc_test/bloc_test.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/how_to_play/how_to_play.dart'; import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_ui/pinball_ui.dart'; @@ -12,17 +11,19 @@ import '../../helpers/helpers.dart'; class _MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {} +class _MockStartGameBloc extends Mock implements StartGameBloc {} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late CharacterThemeCubit characterThemeCubit; + late StartGameBloc startGameBloc; setUp(() async { - Flame.images.prefix = ''; - await Flame.images.load(const DashTheme().animation.keyName); - await Flame.images.load(const AndroidTheme().animation.keyName); - await Flame.images.load(const DinoTheme().animation.keyName); - await Flame.images.load(const SparkyTheme().animation.keyName); + await mockFlameImages(); + characterThemeCubit = _MockCharacterThemeCubit(); + startGameBloc = _MockStartGameBloc(); + whenListen( characterThemeCubit, const Stream.empty(), @@ -33,25 +34,6 @@ void main() { }); group('CharacterSelectionDialog', () { - group('showCharacterSelectionDialog', () { - testWidgets('inflates the dialog', (tester) async { - await tester.pumpApp( - Builder( - builder: (context) { - return TextButton( - onPressed: () => showCharacterSelectionDialog(context), - child: const Text('test'), - ); - }, - ), - characterThemeCubit: characterThemeCubit, - ); - await tester.tap(find.text('test')); - await tester.pump(); - expect(find.byType(CharacterSelectionDialog), findsOneWidget); - }); - }); - testWidgets('selecting a new character calls characterSelected on cubit', (tester) async { await tester.pumpApp( @@ -67,15 +49,22 @@ void main() { testWidgets( 'tapping the select button dismisses the character ' - 'dialog and shows the how to play dialog', (tester) async { + 'dialog and calls CharacterSelected event to the bloc', (tester) async { + whenListen( + startGameBloc, + const Stream.empty(), + initialState: const StartGameState.initial(), + ); + await tester.pumpApp( const CharacterSelectionDialog(), characterThemeCubit: characterThemeCubit, + startGameBloc: startGameBloc, ); await tester.tap(find.byType(PinballButton)); await tester.pumpAndSettle(); expect(find.byType(CharacterSelectionDialog), findsNothing); - expect(find.byType(HowToPlayDialog), findsOneWidget); + verify(() => startGameBloc.add(const CharacterSelected())).called(1); }); testWidgets('updating the selected character updates the preview', diff --git a/test/start_game/bloc/start_game_bloc_test.dart b/test/start_game/bloc/start_game_bloc_test.dart index 6663ff4e..45460fe3 100644 --- a/test/start_game/bloc/start_game_bloc_test.dart +++ b/test/start_game/bloc/start_game_bloc_test.dart @@ -1,32 +1,12 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:pinball/game/game.dart'; import 'package:pinball/start_game/bloc/start_game_bloc.dart'; -class _MockPinballGame extends Mock implements PinballGame {} - -class _MockGameFlowController extends Mock implements GameFlowController {} - void main() { - late PinballGame pinballGame; - - setUp(() { - pinballGame = _MockPinballGame(); - - when( - () => pinballGame.gameFlowController, - ).thenReturn( - _MockGameFlowController(), - ); - }); - group('StartGameBloc', () { blocTest( 'on PlayTapped changes status to selectCharacter', - build: () => StartGameBloc( - game: pinballGame, - ), + build: StartGameBloc.new, act: (bloc) => bloc.add(const PlayTapped()), expect: () => [ const StartGameState( @@ -37,9 +17,7 @@ void main() { blocTest( 'on CharacterSelected changes status to howToPlay', - build: () => StartGameBloc( - game: pinballGame, - ), + build: StartGameBloc.new, act: (bloc) => bloc.add(const CharacterSelected()), expect: () => [ const StartGameState( @@ -50,9 +28,7 @@ void main() { blocTest( 'on HowToPlayFinished changes status to play', - build: () => StartGameBloc( - game: pinballGame, - ), + build: StartGameBloc.new, act: (bloc) => bloc.add(const HowToPlayFinished()), expect: () => [ const StartGameState( diff --git a/test/start_game/widgets/start_game_listener_test.dart b/test/start_game/widgets/start_game_listener_test.dart new file mode 100644 index 00000000..4e25796b --- /dev/null +++ b/test/start_game/widgets/start_game_listener_test.dart @@ -0,0 +1,267 @@ +import 'package:bloc_test/bloc_test.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/how_to_play/how_to_play.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/helpers.dart'; + +class _MockStartGameBloc extends Mock implements StartGameBloc {} + +class _MockGameBloc extends Mock implements GameBloc {} + +class _MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {} + +class _MockPinballPlayer extends Mock implements PinballPlayer {} + +void main() { + late StartGameBloc startGameBloc; + late PinballPlayer pinballPlayer; + late CharacterThemeCubit characterThemeCubit; + + group('StartGameListener', () { + setUp(() async { + await mockFlameImages(); + + startGameBloc = _MockStartGameBloc(); + pinballPlayer = _MockPinballPlayer(); + characterThemeCubit = _MockCharacterThemeCubit(); + }); + + group('on selectCharacter status', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = _MockGameBloc(); + + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + testWidgets( + 'calls onGameStarted event', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.selectCharacter), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + const StartGameListener( + child: SizedBox.shrink(), + ), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + + verify(() => gameBloc.add(const GameStarted())).called(1); + }, + ); + + testWidgets( + 'shows SelectCharacter dialog', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.selectCharacter), + ), + initialState: const StartGameState.initial(), + ); + whenListen( + characterThemeCubit, + Stream.value(const CharacterThemeState.initial()), + initialState: const CharacterThemeState.initial(), + ); + + await tester.pumpApp( + const StartGameListener( + child: SizedBox.shrink(), + ), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + characterThemeCubit: characterThemeCubit, + ); + + await tester.pump(kThemeAnimationDuration); + + expect( + find.byType(CharacterSelectionDialog), + findsOneWidget, + ); + }, + ); + }); + + testWidgets( + 'on howToPlay status shows HowToPlay dialog', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.howToPlay), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + const StartGameListener( + child: SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'do nothing on play status', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.play), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + const StartGameListener( + child: SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsNothing, + ); + expect( + find.byType(CharacterSelectionDialog), + findsNothing, + ); + }, + ); + + testWidgets( + 'do nothing on initial status', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.initial), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + const StartGameListener( + child: SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsNothing, + ); + expect( + find.byType(CharacterSelectionDialog), + findsNothing, + ); + }, + ); + + group('on dismiss HowToPlayDialog', () { + setUp(() { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.howToPlay), + ), + initialState: const StartGameState.initial(), + ); + }); + + testWidgets( + 'adds HowToPlayFinished event', + (tester) async { + await tester.pumpApp( + const StartGameListener( + child: SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsOneWidget, + ); + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsNothing, + ); + await tester.pumpAndSettle(); + + verify( + () => startGameBloc.add(const HowToPlayFinished()), + ).called(1); + }, + ); + + testWidgets( + 'plays the I/O Pinball voice over audio', + (tester) async { + await tester.pumpApp( + const StartGameListener( + child: SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + pinballPlayer: pinballPlayer, + ); + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsOneWidget, + ); + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsNothing, + ); + await tester.pumpAndSettle(); + + verify(() => pinballPlayer.play(PinballAudio.ioPinballVoiceOver)) + .called(1); + }, + ); + }); + }); +} diff --git a/web/index.html b/web/index.html index 107b34ba..f60ae7ce 100644 --- a/web/index.html +++ b/web/index.html @@ -26,14 +26,13 @@ - - + + content="https://firebasestorage.googleapis.com/v0/b/io-pinball.appspot.com/o/images%2Fpinball_share_image.png?alt=media"> + content="https://firebasestorage.googleapis.com/v0/b/io-pinball.appspot.com/o/images%2Fpinball_share_image.png?alt=media">