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/lib/app/view/app.dart b/lib/app/view/app.dart index d778b55b..de46512b 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'; @@ -34,8 +35,11 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _leaderboardRepository), RepositoryProvider.value(value: _pinballAudio), ], - 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..c837c8c5 --- /dev/null +++ b/lib/game/behaviors/bumper_noisy_behavior.dart @@ -0,0 +1,14 @@ +// 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_flame/pinball_flame.dart'; + +class BumperNoisyBehavior extends ContactBehavior with HasGameRef { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + gameRef.audio.bumper(); + } +} diff --git a/lib/game/behaviors/scoring_behavior.dart b/lib/game/behaviors/scoring_behavior.dart new file mode 100644 index 00000000..84597838 --- /dev/null +++ b/lib/game/behaviors/scoring_behavior.dart @@ -0,0 +1,77 @@ +// 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)); + await gameRef.firstChild()!.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..43d6005b 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -17,12 +17,13 @@ class GameBloc extends Bloc { } 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, ), @@ -32,7 +33,7 @@ class GameBloc extends Bloc { void _onScored(Scored event, Emitter emit) { if (!state.isGameOver) { emit( - state.copyWith(score: state.score + event.points), + state.copyWith(roundScore: state.roundScore + event.points), ); } } diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 4ce9042d..2ccb4405 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -26,22 +26,32 @@ enum GameBonus { 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"), + }) : 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, + : 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; @@ -58,20 +68,25 @@ class GameState extends Equatable { /// Determines when the game is over. bool get isGameOver => rounds == 0; + /// 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, }) { 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, @@ -80,7 +95,8 @@ class GameState extends Equatable { @override List get props => [ - score, + totalScore, + roundScore, multiplier, rounds, bonusHistory, diff --git a/lib/game/components/android_acres/android_acres.dart b/lib/game/components/android_acres/android_acres.dart index 3d1a8154..82b71741 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} @@ -20,22 +20,25 @@ class AndroidAcres extends Component { 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/backbox/backbox.dart b/lib/game/components/backbox/backbox.dart new file mode 100644 index 00000000..0ef85fba --- /dev/null +++ b/lib/game/components/backbox/backbox.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:pinball/game/components/backbox/displays/displays.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template backbox} +/// The [Backbox] of the pinball machine. +/// {@endtemplate} +class Backbox extends PositionComponent with HasGameRef, ZIndex { + /// {@macro backbox} + Backbox() + : super( + position: Vector2(0, -87), + anchor: Anchor.bottomCenter, + children: [ + _BackboxSpriteComponent(), + ], + ) { + zIndex = ZIndexes.backbox; + } + + /// Puts [InitialsInputDisplay] on the [Backbox]. + Future initialsInput({ + required int score, + required String characterIconPath, + InitialsOnSubmit? onSubmit, + }) async { + removeAll(children.where((child) => child is! _BackboxSpriteComponent)); + await add( + InitialsInputDisplay( + score: score, + characterIconPath: characterIconPath, + onSubmit: onSubmit, + ), + ); + } +} + +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/displays/displays.dart b/lib/game/components/backbox/displays/displays.dart new file mode 100644 index 00000000..194212ab --- /dev/null +++ b/lib/game/components/backbox/displays/displays.dart @@ -0,0 +1 @@ +export 'initials_input_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/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..2b132656 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'; @@ -12,5 +13,4 @@ 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..9dc81135 100644 --- a/lib/game/components/controlled_ball.dart +++ b/lib/game/components/controlled_ball.dart @@ -67,7 +67,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/dino_desert/dino_desert.dart b/lib/game/components/dino_desert/dino_desert.dart index 4d8cd7b6..9ba9c71b 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), 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_flow_controller.dart b/lib/game/components/game_flow_controller.dart index edc65329..1299e6eb 100644 --- a/lib/game/components/game_flow_controller.dart +++ b/lib/game/components/game_flow_controller.dart @@ -1,7 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; /// {@template game_flow_controller} @@ -20,27 +19,26 @@ class GameFlowController extends ComponentController @override void onNewState(GameState state) { if (state.isGameOver) { - gameOver(); + _initialsInput(); } else { start(); } } - /// Puts the game on a game over state - void gameOver() { + /// Puts the game in the initials input state. + void _initialsInput() { // TODO(erickzanardo): implement score submission and "navigate" to the // next page - component.firstChild()?.gameOverMode( - score: state?.score ?? 0, + component.descendants().whereType().first.initialsInput( + score: state?.displayScore ?? 0, characterIconPath: component.characterTheme.leaderboardIcon.keyName, ); - component.firstChild()?.focusOnBackboard(); + component.firstChild()!.focusOnGameOverBackbox(); } - /// Puts the game on a playing state + /// Puts the game in the 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/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..7ab86553 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -99,8 +99,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 +113,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,6 +129,9 @@ 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(dashTheme.leaderboardIcon.keyName), images.load(sparkyTheme.leaderboardIcon.keyName), images.load(androidTheme.leaderboardIcon.keyName), diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index f9018ee5..aa963a53 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -5,24 +5,25 @@ 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: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.l10n, }) : super(gravity: Vector2(0, 30)) { images.prefix = ''; controller = _GameBallsController(this); @@ -38,6 +39,8 @@ class PinballGame extends Forge2DGame final PinballAudio audio; + final AppLocalizations l10n; + late final GameFlowController gameFlowController; @override @@ -48,7 +51,7 @@ class PinballGame extends Forge2DGame final machine = [ BoardBackgroundSpriteComponent(), Boundaries(), - Backboard.waiting(position: Vector2(0, -88)), + Backbox(), ]; final decals = [ GoogleWord(position: Vector2(-4.25, 1.8)), @@ -78,10 +81,10 @@ class PinballGame extends Forge2DGame 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 +94,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); } } } @@ -164,26 +168,33 @@ class _GameBallsController extends ComponentController } } -class DebugPinballGame extends PinballGame with FPSCounter { +class DebugPinballGame extends PinballGame with FPSCounter, PanDetector { DebugPinballGame({ required CharacterTheme characterTheme, required PinballAudio audio, + required AppLocalizations l10n, }) : super( characterTheme: characterTheme, audio: audio, + 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 ball = ControlledBall.debug() @@ -191,10 +202,57 @@ class DebugPinballGame extends PinballGame with FPSCounter { firstChild()?.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 ball = ControlledBall.debug()..initialPosition = lineStart!; + final impulse = line * -1 * 10; + ball.add(BallTurboChargingBehavior(impulse: impulse)); + firstChild()?.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..21b9ac5c 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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'; @@ -39,8 +40,16 @@ class PinballGamePage extends StatelessWidget { final pinballAudio = context.read(); final game = isDebugMode - ? DebugPinballGame(characterTheme: characterTheme, audio: audio) - : PinballGame(characterTheme: characterTheme, audio: audio); + ? DebugPinballGame( + characterTheme: characterTheme, + audio: audio, + l10n: context.l10n, + ) + : PinballGame( + characterTheme: characterTheme, + audio: audio, + l10n: context.l10n, + ); final loadables = [ ...game.preLoadAssets(), @@ -51,7 +60,6 @@ class PinballGamePage extends StatelessWidget { return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => StartGameBloc(game: game)), BlocProvider(create: (_) => GameBloc()), BlocProvider(create: (_) => AssetsManagerCubit(loadables)..load()), ], @@ -96,36 +104,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); - 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( + game: game, + 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: 16, + left: leftMargin, + 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..b40536aa 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} 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..76ab9fa4 100644 --- a/lib/game/view/widgets/score_view.dart +++ b/lib/game/view/widgets/score_view.dart @@ -69,7 +69,7 @@ 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(), 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..426fcbe5 100644 --- a/lib/how_to_play/widgets/how_to_play_dialog.dart +++ b/lib/how_to_play/widgets/how_to_play_dialog.dart @@ -3,10 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/gen/gen.dart'; import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_ui/pinball_ui.dart'; import 'package:platform_helper/platform_helper.dart'; @@ -51,24 +49,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 +72,7 @@ class _HowToPlayDialogState extends State { closeTimer = Timer(const Duration(seconds: 3), () { if (mounted) { Navigator.of(context).pop(); + widget.onDismissCallback.call(); } }); } @@ -96,10 +87,17 @@ 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(); + return Future.value(true); + }, + child: PinballDialog( + title: l10n.howToPlay, + subtitle: l10n.tipsForFlips, + child: isMobile ? const _MobileBody() : const _DesktopBody(), + ), ); } } 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/select_character/view/character_selection_page.dart b/lib/select_character/view/character_selection_page.dart index 3b00829b..be671dd1 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, ); 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..df34b324 --- /dev/null +++ b/lib/start_game/widgets/start_game_listener.dart @@ -0,0 +1,95 @@ +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_audio/pinball_audio.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, + required PinballGame game, + }) : _child = child, + _game = game, + super(key: key); + + final Widget _child; + final PinballGame _game; + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + switch (state.status) { + case StartGameStatus.initial: + break; + case StartGameStatus.selectCharacter: + _onSelectCharacter(context); + _game.gameFlowController.start(); + 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) { + final audio = context.read(); + + _showPinballDialog( + context: context, + child: HowToPlayDialog( + onDismissCallback: () { + context.read().add(const HowToPlayFinished()); + audio.ioPinballVoiceOver(); + }, + ), + ); + } + + 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..07257fea 100644 --- a/packages/pinball_audio/lib/src/pinball_audio.dart +++ b/packages/pinball_audio/lib/src/pinball_audio.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:audioplayers/audioplayers.dart'; import 'package:flame_audio/audio_pool.dart'; import 'package:flame_audio/flame_audio.dart'; @@ -40,6 +42,7 @@ class PinballAudio { LoopSingleAudio? loopSingleAudio, PreCacheSingleAudio? preCacheSingleAudio, ConfigureAudioCache? configureAudioCache, + Random? seed, }) : _createAudioPool = createAudioPool ?? AudioPool.create, _playSingleAudio = playSingleAudio ?? FlameAudio.audioCache.play, _loopSingleAudio = loopSingleAudio ?? FlameAudio.audioCache.loop, @@ -48,7 +51,8 @@ class PinballAudio { _configureAudioCache = configureAudioCache ?? ((AudioCache a) { a.prefix = ''; - }); + }), + _seed = seed ?? Random(); final CreateAudioPool _createAudioPool; @@ -60,14 +64,24 @@ class PinballAudio { final ConfigureAudioCache _configureAudioCache; - late AudioPool _scorePool; + final Random _seed; + + late AudioPool _bumperAPool; + + late AudioPool _bumperBPool; /// Loads the sounds effects into the memory Future load() async { _configureAudioCache(FlameAudio.audioCache); - _scorePool = await _createAudioPool( - _prefixFile(Assets.sfx.plim), + _bumperAPool = await _createAudioPool( + _prefixFile(Assets.sfx.bumperA), + maxPlayers: 4, + prefix: '', + ); + + _bumperBPool = await _createAudioPool( + _prefixFile(Assets.sfx.bumperB), maxPlayers: 4, prefix: '', ); @@ -79,9 +93,9 @@ class PinballAudio { ]); } - /// Plays the basic score sound - void score() { - _scorePool.start(); + /// Plays a random bumper sfx. + void bumper() { + (_seed.nextBool() ? _bumperAPool : _bumperBPool).start(volume: 0.6); } /// Plays the google word bonus diff --git a/packages/pinball_audio/test/src/pinball_audio_test.dart b/packages/pinball_audio/test/src/pinball_audio_test.dart index 9d6dff98..916d0f34 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,6 +50,7 @@ void main() { late _MockPlaySingleAudio playSingleAudio; late _MockLoopSingleAudio loopSingleAudio; late _PreCacheSingleAudio preCacheSingleAudio; + late Random seed; late PinballAudio audio; setUpAll(() { @@ -74,12 +79,15 @@ void main() { preCacheSingleAudio = _MockPreCacheSingleAudio(); when(() => preCacheSingleAudio.onCall(any())).thenAnswer((_) async {}); + seed = _MockRandom(); + audio = PinballAudio( configureAudioCache: configureAudioCache.onCall, createAudioPool: createAudioPool.onCall, playSingleAudio: playSingleAudio.onCall, loopSingleAudio: loopSingleAudio.onCall, preCacheSingleAudio: preCacheSingleAudio.onCall, + seed: seed, ); }); @@ -88,12 +96,20 @@ void main() { }); group('load', () { - test('creates the score pool', () async { + test('creates the bumpers pools', () async { await audio.load(); verify( () => createAudioPool.onCall( - 'packages/pinball_audio/${Assets.sfx.plim}', + 'packages/pinball_audio/${Assets.sfx.bumperA}', + maxPlayers: 4, + prefix: '', + ), + ).called(1); + + verify( + () => createAudioPool.onCall( + 'packages/pinball_audio/${Assets.sfx.bumperB}', maxPlayers: 4, prefix: '', ), @@ -137,22 +153,52 @@ 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 audio.load(); + audio.bumper(); + + 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 audio.load(); + audio.bumper(); - verify(audioPool.start).called(1); + verify(() => bumperBPool.start(volume: 0.6)).called(1); + }); }); }); 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/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/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/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index da9747c4..93273683 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(); @@ -49,20 +50,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 +130,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(); 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/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.dart b/packages/pinball_components/lib/src/components/ball.dart deleted file mode 100644 index 7469396a..00000000 --- a/packages/pinball_components/lib/src/components/ball.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; -import 'dart:ui'; - -import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/widgets.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template ball} -/// A solid, [BodyType.dynamic] sphere that rolls and bounces around. -/// {@endtemplate} -class Ball extends BodyComponent - with Layered, InitialPosition, ZIndex { - /// {@macro ball} - Ball({ - required this.baseColor, - }) : super( - renderBody: false, - children: [ - _BallSpriteComponent()..tint(baseColor.withOpacity(0.5)), - ], - ) { - // TODO(ruimiguel): while developing Ball can be launched by clicking mouse, - // and default layer is Layer.all. But on final game Ball will be always be - // be launched from Plunger and LauncherRamp will modify it to Layer.board. - // We need to see what happens if Ball appears from other place like nest - // bumper, it will need to explicit change layer to Layer.board then. - layer = Layer.board; - } - - /// 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, - ); - - return world.createBody(bodyDef)..createFixture(fixtureDef); - } - - /// Immediatly and completly [stop]s the ball. - /// - /// The [Ball] will no longer be affected by any forces, including it's - /// weight and those emitted from collisions. - // TODO(allisonryan0002): prevent motion from contact with other balls. - void stop() { - body - ..gravityScale = Vector2.zero() - ..linearVelocity = Vector2.zero() - ..angularVelocity = 0; - } - - /// Allows the [Ball] to be affected by forces. - /// - /// If previously [stop]ped, the previous ball's velocity is not kept. - 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()); - } - - @override - void update(double dt) { - super.update(dt); - - _rescaleSize(); - _setPositionalGravity(); - } - - void _rescaleSize() { - final boardHeight = BoardDimensions.bounds.height; - const maxShrinkValue = BoardDimensions.perspectiveShrinkFactor; - - final standardizedYPosition = body.position.y + (boardHeight / 2); - - final scaleFactor = maxShrinkValue + - ((standardizedYPosition / boardHeight) * (1 - maxShrinkValue)); - - body.fixtures.first.shape.radius = (size.x / 2) * scaleFactor; - - // TODO(alestiago): Revisit and see if there's a better way to do this. - final spriteComponent = firstChild<_BallSpriteComponent>(); - spriteComponent?.scale = Vector2.all(scaleFactor); - } - - void _setPositionalGravity() { - final defaultGravity = gameRef.world.gravity.y; - final maxXDeviationFromCenter = BoardDimensions.bounds.width / 2; - const maxXGravityPercentage = - (1 - BoardDimensions.perspectiveShrinkFactor) / 2; - final xDeviationFromCenter = body.position.x; - - final positionalXForce = ((xDeviationFromCenter / maxXDeviationFromCenter) * - maxXGravityPercentage) * - defaultGravity; - - final positionalYForce = math.sqrt( - math.pow(defaultGravity, 2) - math.pow(positionalXForce, 2), - ); - - body.gravityOverride = Vector2(positionalXForce, positionalYForce); - } -} - -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; - } - - late final Vector2 _textureSize; - - @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, - 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; - } - } -} diff --git a/packages/pinball_components/lib/src/components/ball/ball.dart b/packages/pinball_components/lib/src/components/ball/ball.dart new file mode 100644 index 00000000..9234e69c --- /dev/null +++ b/packages/pinball_components/lib/src/components/ball/ball.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/widgets.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +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 { + /// {@macro ball} + Ball({ + required this.baseColor, + }) : super( + renderBody: false, + children: [ + _BallSpriteComponent()..tint(baseColor.withOpacity(0.5)), + BallScalingBehavior(), + BallGravitatingBehavior(), + ], + ) { + // TODO(ruimiguel): while developing Ball can be launched by clicking mouse, + // and default layer is Layer.all. But on final game Ball will be always be + // be launched from Plunger and LauncherRamp will modify it to Layer.board. + // We need to see what happens if Ball appears from other place like nest + // bumper, it will need to explicit change layer to Layer.board then. + layer = Layer.board; + } + + /// Creates a [Ball] without any behaviors. + /// + /// This can be used for testing [Ball]'s behaviors in isolation. + @visibleForTesting + Ball.test({required this.baseColor}) + : super( + children: [_BallSpriteComponent()], + ); + + /// 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 bodyDef = BodyDef( + position: initialPosition, + type: BodyType.dynamic, + userData: this, + ); + + return world.createBody(bodyDef)..createFixtureFromShape(shape, 1); + } + + /// Immediatly and completly [stop]s the ball. + /// + /// The [Ball] will no longer be affected by any forces, including it's + /// weight and those emitted from collisions. + // TODO(allisonryan0002): prevent motion from contact with other balls. + void stop() { + body + ..gravityScale = Vector2.zero() + ..linearVelocity = Vector2.zero() + ..angularVelocity = 0; + } + + /// Allows the [Ball] to be affected by forces. + /// + /// If previously [stop]ped, the previous ball's velocity is not kept. + void resume() { + body.gravityScale = Vector2(1, 1); + } +} + +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; + } +} diff --git a/packages/pinball_components/lib/src/components/ball/behaviors/ball_gravitating_behavior.dart b/packages/pinball_components/lib/src/components/ball/behaviors/ball_gravitating_behavior.dart new file mode 100644 index 00000000..bad129a6 --- /dev/null +++ b/packages/pinball_components/lib/src/components/ball/behaviors/ball_gravitating_behavior.dart @@ -0,0 +1,35 @@ +import 'dart:math' as math; + +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'; + +/// Scales the ball's gravity according to its position on the board. +class BallGravitatingBehavior extends Component + with ParentIsA, HasGameRef { + @override + void update(double dt) { + super.update(dt); + final defaultGravity = gameRef.world.gravity.y; + + final maxXDeviationFromCenter = BoardDimensions.bounds.width / 2; + const maxXGravityPercentage = + (1 - BoardDimensions.perspectiveShrinkFactor) / 2; + final xDeviationFromCenter = parent.body.position.x; + + final positionalXForce = ((xDeviationFromCenter / maxXDeviationFromCenter) * + maxXGravityPercentage) * + defaultGravity; + final positionalYForce = math.sqrt( + math.pow(defaultGravity, 2) - math.pow(positionalXForce, 2), + ); + + final gravityOverride = parent.body.gravityOverride; + if (gravityOverride != null) { + gravityOverride.setValues(positionalXForce, positionalYForce); + } else { + parent.body.gravityOverride = Vector2(positionalXForce, positionalYForce); + } + } +} diff --git a/packages/pinball_components/lib/src/components/ball/behaviors/ball_scaling_behavior.dart b/packages/pinball_components/lib/src/components/ball/behaviors/ball_scaling_behavior.dart new file mode 100644 index 00000000..7fc06fb1 --- /dev/null +++ b/packages/pinball_components/lib/src/components/ball/behaviors/ball_scaling_behavior.dart @@ -0,0 +1,24 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Scales the ball's body and sprite according to its position on the board. +class BallScalingBehavior extends Component with ParentIsA { + @override + void update(double dt) { + super.update(dt); + final boardHeight = BoardDimensions.bounds.height; + const maxShrinkValue = BoardDimensions.perspectiveShrinkFactor; + + final standardizedYPosition = parent.body.position.y + (boardHeight / 2); + final scaleFactor = maxShrinkValue + + ((standardizedYPosition / boardHeight) * (1 - maxShrinkValue)); + + parent.body.fixtures.first.shape.radius = (Ball.size.x / 2) * scaleFactor; + + parent.firstChild()!.scale.setValues( + scaleFactor, + scaleFactor, + ); + } +} 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 new file mode 100644 index 00000000..1068a20e --- /dev/null +++ b/packages/pinball_components/lib/src/components/ball/behaviors/behaviors.dart @@ -0,0 +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/components.dart b/packages/pinball_components/lib/src/components/components.dart index c5ea7f9f..2a3d5061 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -1,8 +1,7 @@ export 'android_animatronic.dart'; export 'android_bumper/android_bumper.dart'; export 'android_spaceship/android_spaceship.dart'; -export 'backboard/backboard.dart'; -export 'ball.dart'; +export 'ball/ball.dart'; export 'baseboard.dart'; export 'board_background_sprite_component.dart'; export 'board_dimensions.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'; 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/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/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..bee6fd02 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,8 @@ flutter: - assets/images/multiplier/x5/ - assets/images/multiplier/x6/ - assets/images/score/ + - assets/images/backbox/ + - assets/images/flapper/ flutter_gen: line_length: 80 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/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..a66459a6 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 @@ -13,8 +13,9 @@ class BallBoosterGame extends LineGame { @override void onLine(Vector2 line) { final ball = Ball(baseColor: Colors.transparent); - add(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/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_test.dart b/packages/pinball_components/test/src/components/ball/ball_test.dart similarity index 60% rename from packages/pinball_components/test/src/components/ball_test.dart rename to packages/pinball_components/test/src/components/ball/ball_test.dart index 26a03886..655836a0 100644 --- a/packages/pinball_components/test/src/components/ball_test.dart +++ b/packages/pinball_components/test/src/components/ball/ball_test.dart @@ -1,23 +1,32 @@ // 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 '../../helpers/helpers.dart'; +import '../../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); final flameTester = FlameTester(TestGame.new); group('Ball', () { + const baseColor = Color(0xFFFFFFFF); + + test( + 'can be instantiated', + () { + expect(Ball(baseColor: baseColor), isA()); + expect(Ball.test(baseColor: baseColor), isA()); + }, + ); + flameTester.test( 'loads correctly', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ready(); await game.ensureAdd(ball); @@ -25,11 +34,31 @@ void main() { }, ); + group('adds', () { + flameTester.test('a BallScalingBehavior', (game) async { + final ball = Ball(baseColor: baseColor); + await game.ensureAdd(ball); + expect( + ball.descendants().whereType().length, + equals(1), + ); + }); + + flameTester.test('a BallGravitatingBehavior', (game) async { + final ball = Ball(baseColor: baseColor); + await game.ensureAdd(ball); + expect( + ball.descendants().whereType().length, + equals(1), + ); + }); + }); + group('body', () { flameTester.test( 'is dynamic', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); expect(ball.body.bodyType, equals(BodyType.dynamic)); @@ -38,7 +67,7 @@ void main() { group('can be moved', () { flameTester.test('by its weight', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); game.update(1); @@ -46,7 +75,7 @@ void main() { }); flameTester.test('by applying velocity', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); ball.body.gravityScale = Vector2.zero(); @@ -61,7 +90,7 @@ void main() { flameTester.test( 'exists', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); expect(ball.body.fixtures[0], isA()); @@ -71,7 +100,7 @@ void main() { flameTester.test( 'is dense', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); final fixture = ball.body.fixtures[0]; @@ -82,7 +111,7 @@ void main() { flameTester.test( 'shape is circular', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); final fixture = ball.body.fixtures[0]; @@ -94,7 +123,7 @@ void main() { flameTester.test( 'has Layer.all as default filter maskBits', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ready(); await game.ensureAdd(ball); await game.ready(); @@ -108,7 +137,7 @@ void main() { group('stop', () { group("can't be moved", () { flameTester.test('by its weight', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); ball.stop(); @@ -116,19 +145,6 @@ void main() { expect(ball.body.position, equals(ball.initialPosition)); }); }); - - // TODO(allisonryan0002): delete or retest this if/when solution is added - // to prevent forces on a ball while stopped. - - // flameTester.test('by applying velocity', (game) async { - // final ball = Ball(baseColor: Colors.blue); - // await game.ensureAdd(ball); - // ball.stop(); - - // ball.body.linearVelocity.setValues(10, 10); - // game.update(1); - // expect(ball.body.position, equals(ball.initialPosition)); - // }); }); group('resume', () { @@ -136,7 +152,7 @@ void main() { flameTester.test( 'by its weight when previously stopped', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); ball.stop(); ball.resume(); @@ -149,7 +165,7 @@ void main() { flameTester.test( 'by applying velocity when previously stopped', (game) async { - final ball = Ball(baseColor: Colors.blue); + final ball = Ball(baseColor: baseColor); await game.ensureAdd(ball); ball.stop(); ball.resume(); @@ -162,50 +178,5 @@ void main() { ); }); }); - - group('boost', () { - flameTester.test('applies an impulse to the ball', (game) async { - final ball = Ball(baseColor: Colors.blue); - 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: Colors.blue); - 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: Colors.blue); - 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 new file mode 100644 index 00000000..d78df37a --- /dev/null +++ b/packages/pinball_components/test/src/components/ball/behaviors/ball_gravitating_behavior_test.dart @@ -0,0 +1,62 @@ +// 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 '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final asset = Assets.images.ball.ball.keyName; + final flameTester = FlameTester(() => TestGame([asset])); + + group('BallGravitatingBehavior', () { + const baseColor = Color(0xFFFFFFFF); + test('can be instantiated', () { + expect( + BallGravitatingBehavior(), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final ball = Ball.test(baseColor: baseColor); + final behavior = BallGravitatingBehavior(); + await ball.add(behavior); + await game.ensureAdd(ball); + expect( + ball.firstChild(), + equals(behavior), + ); + }); + + flameTester.test( + "overrides the body's horizontal gravity symmetrically", + (game) async { + final ball1 = Ball.test(baseColor: baseColor) + ..initialPosition = Vector2(10, 0); + await ball1.add(BallGravitatingBehavior()); + + final ball2 = Ball.test(baseColor: baseColor) + ..initialPosition = Vector2(-10, 0); + await ball2.add(BallGravitatingBehavior()); + + await game.ensureAddAll([ball1, ball2]); + game.update(1); + + expect( + ball1.body.gravityOverride!.x, + equals(-ball2.body.gravityOverride!.x), + ); + expect( + ball1.body.gravityOverride!.y, + equals(ball2.body.gravityOverride!.y), + ); + }, + ); + }); +} 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 new file mode 100644 index 00000000..0aeeda98 --- /dev/null +++ b/packages/pinball_components/test/src/components/ball/behaviors/ball_scaling_behavior_test.dart @@ -0,0 +1,84 @@ +// 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 '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final asset = Assets.images.ball.ball.keyName; + final flameTester = FlameTester(() => TestGame([asset])); + + group('BallScalingBehavior', () { + const baseColor = Color(0xFFFFFFFF); + test('can be instantiated', () { + expect( + BallScalingBehavior(), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final ball = Ball.test(baseColor: baseColor); + final behavior = BallScalingBehavior(); + await ball.add(behavior); + await game.ensureAdd(ball); + expect( + ball.firstChild(), + equals(behavior), + ); + }); + + flameTester.test('scales the shape radius', (game) async { + final ball1 = Ball.test(baseColor: baseColor) + ..initialPosition = Vector2(0, 10); + await ball1.add(BallScalingBehavior()); + + final ball2 = Ball.test(baseColor: baseColor) + ..initialPosition = Vector2(0, -10); + await ball2.add(BallScalingBehavior()); + + await game.ensureAddAll([ball1, ball2]); + game.update(1); + + final shape1 = ball1.body.fixtures.first.shape; + final shape2 = ball2.body.fixtures.first.shape; + expect( + shape1.radius, + greaterThan(shape2.radius), + ); + }); + + flameTester.test( + 'scales the sprite', + (game) async { + final ball1 = Ball.test(baseColor: baseColor) + ..initialPosition = Vector2(0, 10); + await ball1.add(BallScalingBehavior()); + + final ball2 = Ball.test(baseColor: baseColor) + ..initialPosition = Vector2(0, -10); + await ball2.add(BallScalingBehavior()); + + await game.ensureAddAll([ball1, ball2]); + game.update(1); + + final sprite1 = ball1.firstChild()!; + final sprite2 = ball2.firstChild()!; + expect( + sprite1.scale.x, + greaterThan(sprite2.scale.x), + ); + expect( + sprite1.scale.y, + greaterThan(sprite2.scale.y), + ); + }, + ); + }); +} 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..00f34832 --- /dev/null +++ b/packages/pinball_components/test/src/components/ball/behaviors/ball_turbo_charging_behavior_test.dart @@ -0,0 +1,94 @@ +// ignore_for_file: cascade_invocations + +import 'dart:ui'; + +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:pinball_components/pinball_components.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'BallTurboChargingBehavior', + () { + final assets = [Assets.images.ball.ball.keyName]; + final flameTester = FlameTester(() => TestGame(assets)); + const baseColor = Color(0xFFFFFFFF); + + test('can be instantiated', () { + expect( + BallTurboChargingBehavior(impulse: Vector2.zero()), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final ball = Ball.test(baseColor: baseColor); + 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(baseColor: baseColor); + 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(baseColor: baseColor); + 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(baseColor: baseColor); + 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/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/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/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/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/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/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..8d458574 100644 --- a/packages/pinball_flame/lib/pinball_flame.dart +++ b/packages/pinball_flame/lib/pinball_flame.dart @@ -4,5 +4,6 @@ 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/pinball_forge2d_game.dart b/packages/pinball_flame/lib/src/pinball_forge2d_game.dart new file mode 100644 index 00000000..118baad9 --- /dev/null +++ b/packages/pinball_flame/lib/src/pinball_forge2d_game.dart @@ -0,0 +1,44 @@ +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(min(dt, 1 / 60)); + } + + @override + Vector2 screenToFlameWorld(Vector2 position) { + throw UnimplementedError(); + } + + @override + Vector2 screenToWorld(Vector2 position) { + throw UnimplementedError(); + } + + @override + Vector2 worldToScreen(Vector2 position) { + throw UnimplementedError(); + } +} 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..872f8b97 --- /dev/null +++ b/packages/pinball_flame/test/src/pinball_forge2d_game_test.dart @@ -0,0 +1,51 @@ +// 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, + ); + }, + ); + }); +} 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/game/behaviors/bumper_noisy_behavior_test.dart b/test/game/behaviors/bumper_noisy_behavior_test.dart new file mode 100644 index 00000000..b288e4c6 --- /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 _MockPinballAudio extends Mock implements PinballAudio {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('BumperNoisyBehavior', () {}); + + late PinballAudio audio; + final flameTester = FlameTester( + () => EmptyPinballTestGame(audio: audio), + ); + + setUp(() { + audio = _MockPinballAudio(); + }); + + 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(audio.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..47903d8a --- /dev/null +++ b/test/game/behaviors/scoring_behavior_test.dart @@ -0,0 +1,211 @@ +// 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: [], + ); + 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..5927291b 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -6,7 +6,7 @@ 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)); }); @@ -19,21 +19,17 @@ void main() { bloc.add(const RoundLost()); }, expect: () => [ - const GameState( - score: 0, - multiplier: 1, - rounds: 2, - bonusHistory: [], - ), + isA()..having((state) => state.rounds, 'rounds', 2), ], ); 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: [], @@ -43,8 +39,8 @@ void main() { }, 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) ], ); @@ -53,7 +49,8 @@ void main() { 'when round is lost', build: GameBloc.new, seed: () => const GameState( - score: 5, + totalScore: 10, + roundScore: 5, multiplier: 3, rounds: 2, bonusHistory: [], @@ -62,9 +59,7 @@ void main() { 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) ], ); }); @@ -79,10 +74,10 @@ void main() { ..add(const Scored(points: 3)), expect: () => [ isA() - ..having((state) => state.score, 'score', 2) + ..having((state) => state.roundScore, 'roundScore', 2) ..having((state) => state.isGameOver, 'isGameOver', false), isA() - ..having((state) => state.score, 'score', 5) + ..having((state) => state.roundScore, 'roundScore', 5) ..having((state) => state.isGameOver, 'isGameOver', false), ], ); @@ -99,15 +94,15 @@ 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), 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), 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), ], @@ -124,11 +119,9 @@ void main() { ..add(const MultiplierIncreased()), expect: () => [ isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 2) ..having((state) => state.isGameOver, 'isGameOver', false), isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 3) ..having((state) => state.isGameOver, 'isGameOver', false), ], @@ -139,7 +132,8 @@ 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: [], @@ -160,15 +154,12 @@ void main() { }, expect: () => [ isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 1) ..having((state) => state.isGameOver, 'isGameOver', false), isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 1) ..having((state) => state.isGameOver, 'isGameOver', false), isA() - ..having((state) => state.score, 'score', 0) ..having((state) => state.multiplier, 'multiplier', 1) ..having((state) => state.isGameOver, 'isGameOver', true), ], diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index add25e05..b59115a3 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -8,14 +8,16 @@ void main() { test('supports value equality', () { expect( GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: const [], ), equals( const GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: [], @@ -28,7 +30,8 @@ void main() { test('can be instantiated', () { expect( const GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: [], @@ -40,11 +43,29 @@ 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 [], + ), + throwsAssertionError, + ); + }, + ); + + test( + 'throws AssertionError ' + 'when roundScore is negative', + () { + expect( + () => GameState( + totalScore: 0, + roundScore: -1, multiplier: 1, rounds: 3, bonusHistory: const [], @@ -60,7 +81,8 @@ void main() { () { expect( () => GameState( - score: 1, + totalScore: 0, + roundScore: 1, multiplier: 0, rounds: 3, bonusHistory: const [], @@ -76,7 +98,8 @@ void main() { () { expect( () => GameState( - score: 1, + totalScore: 0, + roundScore: 1, multiplier: 1, rounds: -1, bonusHistory: const [], @@ -91,7 +114,8 @@ void main() { 'is true ' 'when no rounds are left', () { const gameState = GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 0, bonusHistory: [], @@ -103,7 +127,8 @@ void main() { 'is false ' 'when one 1 round left', () { const gameState = GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 1, bonusHistory: [], @@ -115,16 +140,17 @@ void main() { 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: [], ); expect( - () => gameState.copyWith(score: gameState.score - 1), + () => gameState.copyWith(totalScore: gameState.totalScore - 1), throwsAssertionError, ); }, @@ -135,7 +161,8 @@ void main() { 'when no argument specified', () { const gameState = GameState( - score: 2, + totalScore: 0, + roundScore: 2, multiplier: 1, rounds: 3, bonusHistory: [], @@ -152,13 +179,15 @@ void main() { 'when all arguments specified', () { const gameState = GameState( - score: 2, + totalScore: 0, + roundScore: 2, multiplier: 1, rounds: 3, bonusHistory: [], ); 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], @@ -167,7 +196,8 @@ void main() { expect( gameState.copyWith( - score: otherGameState.score, + totalScore: otherGameState.totalScore, + roundScore: otherGameState.roundScore, multiplier: otherGameState.multiplier, rounds: otherGameState.rounds, bonusHistory: otherGameState.bonusHistory, 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/backbox/backbox_test.dart b/test/game/components/backbox/backbox_test.dart new file mode 100644 index 00000000..341198f8 --- /dev/null +++ b/test/game/components/backbox/backbox_test.dart @@ -0,0 +1,98 @@ +// 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/initials_input_display.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 _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.marquee.keyName, + Assets.images.backbox.displayDivider.keyName, + ]; + final flameTester = FlameTester( + () => EmptyPinballTestGame( + assets: assets, + l10n: _MockAppLocalizations(), + ), + ); + + group('Backbox', () { + flameTester.test( + 'loads correctly', + (game) async { + final backbox = Backbox(); + 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()); + await tester.pump(); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('../golden/backbox.png'), + ); + }, + ); + + flameTester.test( + 'initialsInput adds InitialsInputDisplay', + (game) async { + final backbox = Backbox(); + await game.ensureAdd(backbox); + await backbox.initialsInput( + score: 0, + characterIconPath: characterIconPath, + onSubmit: (_) {}, + ); + await game.ready(); + + expect(backbox.firstChild(), isNotNull); + }, + ); + }); +} 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/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..d8d31b4e 100644 --- a/test/game/components/controlled_ball_test.dart +++ b/test/game/components/controlled_ball_test.dart @@ -1,8 +1,8 @@ // ignore_for_file: cascade_invocations import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.dart'; import 'package:flame/extensions.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'; @@ -15,7 +15,7 @@ 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; @@ -100,6 +100,7 @@ void main() { group('turboCharge', () { setUpAll(() { registerFallbackValue(Vector2.zero()); + registerFallbackValue(Component()); }); flameBlocTester.testGameWidget( @@ -125,7 +126,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 +142,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..f9561b60 100644 --- a/test/game/components/controlled_flipper_test.dart +++ b/test/game/components/controlled_flipper_test.dart @@ -27,7 +27,8 @@ void main() { blocBuilder: () { final bloc = _MockGameBloc(); const state = GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 0, bonusHistory: [], diff --git a/test/game/components/controlled_plunger_test.dart b/test/game/components/controlled_plunger_test.dart index c832e24a..4d9c9c74 100644 --- a/test/game/components/controlled_plunger_test.dart +++ b/test/game/components/controlled_plunger_test.dart @@ -22,7 +22,8 @@ void main() { blocBuilder: () { final bloc = _MockGameBloc(); const state = GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 0, bonusHistory: [], 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/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_flow_controller_test.dart index c85d0b52..e403396d 100644 --- a/test/game/components/game_flow_controller_test.dart +++ b/test/game/components/game_flow_controller_test.dart @@ -5,12 +5,11 @@ 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 {} @@ -24,7 +23,8 @@ void main() { 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 [], @@ -40,7 +40,7 @@ void main() { group('onNewState', () { late PinballGame game; - late Backboard backboard; + late Backbox backbox; late CameraController cameraController; late GameFlowController gameFlowController; late PinballAudio pinballAudio; @@ -48,26 +48,26 @@ void main() { setUp(() { game = _MockPinballGame(); - backboard = _MockBackboard(); + backbox = _MockBackbox(); cameraController = _MockCameraController(); gameFlowController = GameFlowController(game); overlays = _MockActiveOverlaysNotifier(); pinballAudio = _MockPinballAudio(); when( - () => backboard.gameOverMode( + () => backbox.initialsInput( score: any(named: 'score'), characterIconPath: any(named: 'characterIconPath'), onSubmit: any(named: 'onSubmit'), ), ).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()); @@ -75,11 +75,13 @@ void main() { }); 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, + totalScore: 0, + roundScore: 10, multiplier: 1, rounds: 0, bonusHistory: const [], @@ -87,22 +89,21 @@ void main() { ); verify( - () => backboard.gameOverMode( + () => backbox.initialsInput( score: 0, characterIconPath: any(named: 'characterIconPath'), onSubmit: any(named: 'onSubmit'), ), ).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()); - verify(backboard.waitingMode).called(1); verify(cameraController.focusOnGame).called(1); verify(() => overlays.remove(PinballGame.playButtonOverlay)) .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..b294d350 100644 --- a/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart +++ b/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart @@ -74,7 +74,8 @@ 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 [], diff --git a/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart b/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart index 40a952f1..ca3c5921 100644 --- a/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart +++ b/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart @@ -56,7 +56,8 @@ 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, bonusHistory: const [], @@ -71,7 +72,8 @@ 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, 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..ca31f280 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -25,6 +25,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,9 +40,8 @@ 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, Assets.images.ball.flameEffect.keyName, @@ -124,6 +129,9 @@ 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, ]; late GameBloc gameBloc; @@ -137,19 +145,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. @@ -244,6 +249,8 @@ void main() { final newState = _MockGameState(); when(() => newState.isGameOver).thenReturn(false); + await game.ready(); + expect( game.descendants().whereType().length, greaterThan(0), @@ -323,7 +330,7 @@ void main() { (flipper) => flipper.side == BoardSide.left, ); - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); expect(flippers.first.body.linearVelocity.y, isNegative); }); @@ -346,7 +353,7 @@ void main() { (flipper) => flipper.side == BoardSide.right, ); - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); expect(flippers.first.body.linearVelocity.y, isNegative); }); @@ -369,14 +376,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 +407,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 +478,7 @@ void main() { final plunger = game.descendants().whereType().first; - game.onTapDown(tapDownEvent); + game.onTapDown(0, tapDownEvent); game.update(1); @@ -436,6 +488,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 +506,74 @@ 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(); + + expect( + game.descendants().whereType().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..19c860da 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,17 @@ void main() { late GameBloc gameBloc; const initialState = GameState( - score: 1000, + totalScore: 0, + roundScore: 1000, multiplier: 1, rounds: 1, bonusHistory: [], ); 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 +71,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..049aba95 100644 --- a/test/game/view/widgets/round_count_display_test.dart +++ b/test/game/view/widgets/round_count_display_test.dart @@ -13,7 +13,8 @@ void main() { group('RoundCountDisplay renders', () { late GameBloc gameBloc; const initialState = GameState( - score: 0, + totalScore: 0, + roundScore: 0, multiplier: 1, rounds: 3, bonusHistory: [], diff --git a/test/game/view/widgets/score_view_test.dart b/test/game/view/widgets/score_view_test.dart index 695dc6e1..0e4acafc 100644 --- a/test/game/view/widgets/score_view_test.dart +++ b/test/game/view/widgets/score_view_test.dart @@ -15,9 +15,11 @@ 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: [], @@ -42,7 +44,10 @@ void main() { ); await tester.pump(); - expect(find.text(score.formatScore()), findsOneWidget); + expect( + find.text(initialState.displayScore.formatScore()), + findsOneWidget, + ); }); testWidgets('renders game over', (tester) async { @@ -69,17 +74,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/test_games.dart b/test/helpers/test_games.dart index 5e67532a..aa1ef777 100644 --- a/test/helpers/test_games.dart +++ b/test/helpers/test_games.dart @@ -2,15 +2,19 @@ import 'dart:async'; +import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.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 _MockAppLocalizations extends Mock implements AppLocalizations {} + class TestGame extends Forge2DGame with FlameBloc { TestGame() { images.prefix = ''; @@ -22,10 +26,12 @@ class PinballTestGame extends PinballGame { List? assets, PinballAudio? audio, CharacterTheme? theme, + AppLocalizations? l10n, }) : _assets = assets, super( audio: audio ?? _MockPinballAudio(), characterTheme: theme ?? const DashTheme(), + l10n: l10n ?? _MockAppLocalizations(), ); final List? _assets; @@ -43,10 +49,12 @@ class DebugPinballTestGame extends DebugPinballGame { List? assets, PinballAudio? audio, CharacterTheme? theme, + AppLocalizations? l10n, }) : _assets = assets, super( audio: audio ?? _MockPinballAudio(), characterTheme: theme ?? const DashTheme(), + l10n: l10n ?? _MockAppLocalizations(), ); final List? _assets; @@ -65,10 +73,34 @@ class EmptyPinballTestGame extends PinballTestGame { List? assets, PinballAudio? audio, CharacterTheme? theme, + AppLocalizations? l10n, + }) : super( + assets: assets, + audio: audio, + 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, + PinballAudio? audio, + CharacterTheme? theme, + AppLocalizations? l10n, }) : super( assets: assets, audio: audio, 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/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..ac548a93 100644 --- a/test/start_game/bloc/start_game_bloc_test.dart +++ b/test/start_game/bloc/start_game_bloc_test.dart @@ -24,9 +24,7 @@ void main() { 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 +35,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 +46,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..ca646bc9 --- /dev/null +++ b/test/start_game/widgets/start_game_listener_test.dart @@ -0,0 +1,269 @@ +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 _MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {} + +class _MockPinballGame extends Mock implements PinballGame {} + +class _MockGameFlowController extends Mock implements GameFlowController {} + +class _MockPinballAudio extends Mock implements PinballAudio {} + +void main() { + late StartGameBloc startGameBloc; + late PinballGame pinballGame; + late PinballAudio pinballAudio; + late CharacterThemeCubit characterThemeCubit; + + group('StartGameListener', () { + setUp(() async { + await mockFlameImages(); + + startGameBloc = _MockStartGameBloc(); + pinballGame = _MockPinballGame(); + pinballAudio = _MockPinballAudio(); + characterThemeCubit = _MockCharacterThemeCubit(); + }); + + group('on selectCharacter status', () { + testWidgets( + 'calls start on the game controller', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.selectCharacter), + ), + initialState: const StartGameState.initial(), + ); + final gameController = _MockGameFlowController(); + when(() => pinballGame.gameFlowController) + .thenAnswer((_) => gameController); + + await tester.pumpApp( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + verify(gameController.start).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(), + ); + final gameController = _MockGameFlowController(); + when(() => pinballGame.gameFlowController) + .thenAnswer((_) => gameController); + + await tester.pumpApp( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + 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( + StartGameListener( + game: pinballGame, + child: const 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( + StartGameListener( + game: pinballGame, + child: const 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( + StartGameListener( + game: pinballGame, + child: const 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( + StartGameListener( + game: pinballGame, + child: const 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( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + pinballAudio: pinballAudio, + ); + 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(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">