diff --git a/assets/images/bonus_animation/google_word.png b/assets/images/bonus_animation/google_word.png index 7adab3b4..c4ab2948 100644 Binary files a/assets/images/bonus_animation/google_word.png and b/assets/images/bonus_animation/google_word.png differ diff --git a/lib/game/components/android_acres.dart b/lib/game/components/android_acres.dart index 489dc2e5..e7330c1f 100644 --- a/lib/game/components/android_acres.dart +++ b/lib/game/components/android_acres.dart @@ -18,17 +18,17 @@ class AndroidAcres extends Component { AndroidSpaceship(position: Vector2(-26.5, -28.5)), AndroidBumper.a( children: [ - ScoringBehavior(points: 20000), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(-25, 1.3), AndroidBumper.b( children: [ - ScoringBehavior(points: 20000), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(-32.8, -9.2), AndroidBumper.cow( children: [ - ScoringBehavior(points: 20), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(-20.5, -13.8), ], diff --git a/lib/game/components/bottom_group.dart b/lib/game/components/bottom_group.dart index b4a888f4..c13f21be 100644 --- a/lib/game/components/bottom_group.dart +++ b/lib/game/components/bottom_group.dart @@ -51,7 +51,7 @@ class _BottomGroupSide extends Component { final kicker = Kicker( side: _side, children: [ - ScoringBehavior(points: 5000)..applyTo(['bouncy_edge']), + ScoringBehavior(points: Points.fiveThousand)..applyTo(['bouncy_edge']), ], )..initialPosition = Vector2( (22.64 * direction) + centerXAdjustment, diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 5af4efc0..aeb5742e 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -10,6 +10,7 @@ export 'flutter_forest/flutter_forest.dart'; export 'game_flow_controller.dart'; export 'google_word/google_word.dart'; export 'launcher.dart'; +export 'multiballs/multiballs.dart'; export 'multipliers/multipliers.dart'; export 'scoring_behavior.dart'; export 'sparky_scorch.dart'; diff --git a/lib/game/components/dino_desert.dart b/lib/game/components/dino_desert.dart index fc601791..b3ae4ab9 100644 --- a/lib/game/components/dino_desert.dart +++ b/lib/game/components/dino_desert.dart @@ -14,7 +14,8 @@ class DinoDesert extends Component { children: [ ChromeDino( children: [ - ScoringBehavior(points: 200000)..applyTo(['inside_mouth']), + ScoringBehavior(points: Points.twoHundredThousand) + ..applyTo(['inside_mouth']), ], )..initialPosition = Vector2(12.6, -6.9), _BarrierBehindDino(), diff --git a/lib/game/components/flutter_forest/flutter_forest.dart b/lib/game/components/flutter_forest/flutter_forest.dart index 42e5415d..1fb8907b 100644 --- a/lib/game/components/flutter_forest/flutter_forest.dart +++ b/lib/game/components/flutter_forest/flutter_forest.dart @@ -18,22 +18,22 @@ class FlutterForest extends Component with ZIndex { children: [ Signpost( children: [ - ScoringBehavior(points: 20), + ScoringBehavior(points: Points.fiveThousand), ], )..initialPosition = Vector2(8.35, -58.3), DashNestBumper.main( children: [ - ScoringBehavior(points: 200000), + ScoringBehavior(points: Points.twoHundredThousand), ], )..initialPosition = Vector2(18.55, -59.35), DashNestBumper.a( children: [ - ScoringBehavior(points: 20000), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(8.95, -51.95), DashNestBumper.b( children: [ - ScoringBehavior(points: 20000), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(22.3, -46.75), DashAnimatronic()..position = Vector2(20, -66), diff --git a/lib/game/components/google_word/google_word.dart b/lib/game/components/google_word/google_word.dart index 2ce68263..af1faea9 100644 --- a/lib/game/components/google_word/google_word.dart +++ b/lib/game/components/google_word/google_word.dart @@ -16,27 +16,27 @@ class GoogleWord extends Component with ZIndex { children: [ GoogleLetter( 0, - children: [ScoringBehavior(points: 5000)], + children: [ScoringBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(-13.1, 1.72), GoogleLetter( 1, - children: [ScoringBehavior(points: 5000)], + children: [ScoringBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(-8.33, -0.75), GoogleLetter( 2, - children: [ScoringBehavior(points: 5000)], + children: [ScoringBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(-2.88, -1.85), GoogleLetter( 3, - children: [ScoringBehavior(points: 5000)], + children: [ScoringBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(2.88, -1.85), GoogleLetter( 4, - children: [ScoringBehavior(points: 5000)], + children: [ScoringBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(8.33, -0.75), GoogleLetter( 5, - children: [ScoringBehavior(points: 5000)], + children: [ScoringBehavior(points: Points.fiveThousand)], )..initialPosition = position + Vector2(13.1, 1.72), GoogleWordBonusBehavior(), ], diff --git a/lib/game/components/multiballs/behaviors/behaviors.dart b/lib/game/components/multiballs/behaviors/behaviors.dart new file mode 100644 index 00000000..921063dc --- /dev/null +++ b/lib/game/components/multiballs/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'multiballs_behavior.dart'; diff --git a/lib/game/components/multiballs/behaviors/multiballs_behavior.dart b/lib/game/components/multiballs/behaviors/multiballs_behavior.dart new file mode 100644 index 00000000..8b323ff4 --- /dev/null +++ b/lib/game/components/multiballs/behaviors/multiballs_behavior.dart @@ -0,0 +1,28 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Toggle each [Multiball] when there is a bonus ball. +class MultiballsBehavior extends Component + with + HasGameRef, + ParentIsA, + BlocComponent { + @override + bool listenWhen(GameState? previousState, GameState newState) { + final hasChanged = previousState?.bonusHistory != newState.bonusHistory; + final lastBonusIsMultiball = newState.bonusHistory.isNotEmpty && + newState.bonusHistory.last == GameBonus.dashNest; + + return hasChanged && lastBonusIsMultiball; + } + + @override + void onNewState(GameState state) { + parent.children.whereType().forEach((multiball) { + multiball.bloc.onAnimate(); + }); + } +} diff --git a/lib/game/components/multiballs/multiballs.dart b/lib/game/components/multiballs/multiballs.dart new file mode 100644 index 00000000..04f6525a --- /dev/null +++ b/lib/game/components/multiballs/multiballs.dart @@ -0,0 +1,30 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/components/multiballs/behaviors/behaviors.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template multiballs_component} +/// A [SpriteGroupComponent] for the multiball over the board. +/// {@endtemplate} +class Multiballs extends Component with ZIndex { + /// {@macro multiballs_component} + Multiballs() + : super( + children: [ + Multiball.a(), + Multiball.b(), + Multiball.c(), + Multiball.d(), + MultiballsBehavior(), + ], + ) { + zIndex = ZIndexes.decal; + } + + /// Creates a [Multiballs] without any children. + /// + /// This can be used for testing [Multiballs]'s behaviors in isolation. + @visibleForTesting + Multiballs.test(); +} diff --git a/lib/game/components/scoring_behavior.dart b/lib/game/components/scoring_behavior.dart index 3e757eab..e8f51e90 100644 --- a/lib/game/components/scoring_behavior.dart +++ b/lib/game/components/scoring_behavior.dart @@ -12,21 +12,21 @@ import 'package:pinball_flame/pinball_flame.dart'; class ScoringBehavior extends ContactBehavior with HasGameRef { /// {@macro scoring_behavior} ScoringBehavior({ - required int points, + required Points points, }) : _points = points; - final int _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)); + gameRef.read().add(Scored(points: _points.value)); gameRef.audio.score(); gameRef.firstChild()!.add( - ScoreText( - text: _points.toString(), + ScoreComponent( + points: _points, position: other.body.position, ), ); diff --git a/lib/game/components/sparky_scorch.dart b/lib/game/components/sparky_scorch.dart index d461f95f..434e9479 100644 --- a/lib/game/components/sparky_scorch.dart +++ b/lib/game/components/sparky_scorch.dart @@ -16,17 +16,17 @@ class SparkyScorch extends Component { children: [ SparkyBumper.a( children: [ - ScoringBehavior(points: 20000), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(-22.9, -41.65), SparkyBumper.b( children: [ - ScoringBehavior(points: 20000), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(-21.25, -57.9), SparkyBumper.c( children: [ - ScoringBehavior(points: 20000), + ScoringBehavior(points: Points.twentyThousand), ], )..initialPosition = Vector2(-3.3, -52.55), SparkyComputerSensor()..initialPosition = Vector2(-13, -49.9), @@ -47,7 +47,7 @@ class SparkyComputerSensor extends BodyComponent : super( renderBody: false, children: [ - ScoringBehavior(points: 200000), + ScoringBehavior(points: Points.twentyThousand), ], ); diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index 1b8ae0f6..76e1bddc 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -114,6 +114,8 @@ extension PinballGameAssetsX on PinballGame { 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), images.load(components.Assets.images.multiplier.x2.dimmed.keyName), images.load(components.Assets.images.multiplier.x3.lit.keyName), @@ -124,6 +126,10 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.multiplier.x5.dimmed.keyName), images.load(components.Assets.images.multiplier.x6.lit.keyName), images.load(components.Assets.images.multiplier.x6.dimmed.keyName), + images.load(components.Assets.images.score.fiveThousand.keyName), + 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(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 cd5a1c7f..f9018ee5 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -53,6 +53,7 @@ class PinballGame extends Forge2DGame final decals = [ GoogleWord(position: Vector2(-4.25, 1.8)), Multipliers(), + Multiballs(), ]; final characterAreas = [ AndroidAcres(), @@ -87,7 +88,7 @@ class PinballGame extends Forge2DGame // NOTE(wolfen): As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 we need to check it at the highest level manually. if (bounds.contains(info.eventPosition.game.toOffset())) { - descendants().whereType().single.pull(); + descendants().whereType().single.pullFor(2); } else { final leftSide = info.eventPosition.widget.x < canvasSize.x / 2; focusedBoardSide = leftSide ? BoardSide.left : BoardSide.right; @@ -103,21 +104,12 @@ class PinballGame extends Forge2DGame @override void onTapUp(TapUpInfo info) { - final rocket = descendants().whereType().first; - final bounds = rocket.topLeftPosition & rocket.size; - - if (bounds.contains(info.eventPosition.game.toOffset())) { - descendants().whereType().single.release(); - } else { - _moveFlippersDown(); - } + _moveFlippersDown(); super.onTapUp(info); } @override void onTapCancel() { - descendants().whereType().single.release(); - _moveFlippersDown(); super.onTapCancel(); } diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index be11a15c..9ac25cfe 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -44,15 +44,14 @@ class PinballGamePage extends StatelessWidget { ...game.preLoadAssets(), pinballAudio.load(), ...BonusAnimation.loadAssets(), + ...SelectedCharacter.loadAssets(), ]; return MultiBlocProvider( providers: [ BlocProvider(create: (_) => StartGameBloc(game: game)), BlocProvider(create: (_) => GameBloc()), - BlocProvider( - create: (_) => AssetsManagerCubit(loadables)..load(), - ), + BlocProvider(create: (_) => AssetsManagerCubit(loadables)..load()), ], child: PinballGameView(game: game), ); diff --git a/lib/game/view/widgets/bonus_animation.dart b/lib/game/view/widgets/bonus_animation.dart index da67e1aa..35e600f2 100644 --- a/lib/game/view/widgets/bonus_animation.dart +++ b/lib/game/view/widgets/bonus_animation.dart @@ -126,7 +126,7 @@ class _BonusAnimationState extends State ); animation = spriteSheet.createAnimation( row: 0, - stepTime: 1 / 24, + stepTime: 1 / 12, to: spriteSheet.rows * spriteSheet.columns, loop: false, ); 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 766944b9..3dc2c62b 100644 --- a/lib/how_to_play/widgets/how_to_play_dialog.dart +++ b/lib/how_to_play/widgets/how_to_play_dialog.dart @@ -52,7 +52,6 @@ extension on Control { Future showHowToPlayDialog(BuildContext context) { return showDialog( context: context, - barrierDismissible: false, builder: (_) => HowToPlayDialog(), ); } diff --git a/lib/select_character/view/character_selection_page.dart b/lib/select_character/view/character_selection_page.dart index 1df01ad7..5fb0f594 100644 --- a/lib/select_character/view/character_selection_page.dart +++ b/lib/select_character/view/character_selection_page.dart @@ -2,7 +2,6 @@ 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/cubit/character_theme_cubit.dart'; import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_ui/pinball_ui.dart'; @@ -118,19 +117,7 @@ class _CharacterPreview extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - state.characterTheme.name, - style: Theme.of(context).textTheme.headline2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - Expanded(child: state.characterTheme.icon.image()), - ], - ); + return SelectedCharacter(currentCharacter: state.characterTheme); }, ); } diff --git a/lib/select_character/view/selected_character.dart b/lib/select_character/view/selected_character.dart new file mode 100644 index 00000000..68b5ad8a --- /dev/null +++ b/lib/select_character/view/selected_character.dart @@ -0,0 +1,102 @@ +import 'package:flame/components.dart'; +import 'package:flame/flame.dart'; +import 'package:flame/sprite.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// {@template selected_character} +/// Shows an animated version of the character currently selected. +/// {@endtemplate} +class SelectedCharacter extends StatefulWidget { + /// {@macro selected_character} + const SelectedCharacter({ + Key? key, + required this.currentCharacter, + }) : super(key: key); + + /// The character that is selected at the moment. + final CharacterTheme currentCharacter; + + @override + State createState() => _SelectedCharacterState(); + + /// Returns a list of assets to be loaded. + static List loadAssets() { + return [ + Flame.images.load(const DashTheme().animation.keyName), + Flame.images.load(const AndroidTheme().animation.keyName), + Flame.images.load(const DinoTheme().animation.keyName), + Flame.images.load(const SparkyTheme().animation.keyName), + ]; + } +} + +class _SelectedCharacterState extends State + with TickerProviderStateMixin { + SpriteAnimationController? _controller; + + @override + void initState() { + super.initState(); + _setupCharacterAnimation(); + } + + @override + void didUpdateWidget(covariant SelectedCharacter oldWidget) { + super.didUpdateWidget(oldWidget); + _setupCharacterAnimation(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + widget.currentCharacter.name, + style: Theme.of(context).textTheme.headline2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: SpriteAnimationWidget( + controller: _controller!, + anchor: Anchor.center, + ), + ); + }, + ), + ), + ], + ); + } + + void _setupCharacterAnimation() { + final spriteSheet = SpriteSheet.fromColumnsAndRows( + image: Flame.images.fromCache(widget.currentCharacter.animation.keyName), + columns: 12, + rows: 6, + ); + final animation = spriteSheet.createAnimation( + row: 0, + stepTime: 1 / 24, + to: spriteSheet.rows * spriteSheet.columns, + ); + if (_controller != null) _controller?.dispose(); + _controller = SpriteAnimationController(vsync: this, animation: animation) + ..forward() + ..repeat(); + } +} diff --git a/lib/select_character/view/view.dart b/lib/select_character/view/view.dart index 1af489b5..41f82053 100644 --- a/lib/select_character/view/view.dart +++ b/lib/select_character/view/view.dart @@ -1 +1,2 @@ export 'character_selection_page.dart'; +export 'selected_character.dart'; diff --git a/packages/pinball_components/assets/images/multiball/dimmed.png b/packages/pinball_components/assets/images/multiball/dimmed.png new file mode 100644 index 00000000..f7d9407a Binary files /dev/null and b/packages/pinball_components/assets/images/multiball/dimmed.png differ diff --git a/packages/pinball_components/assets/images/multiball/lit.png b/packages/pinball_components/assets/images/multiball/lit.png new file mode 100644 index 00000000..3444309c Binary files /dev/null and b/packages/pinball_components/assets/images/multiball/lit.png differ diff --git a/packages/pinball_components/assets/images/score/five-thousand.png b/packages/pinball_components/assets/images/score/five-thousand.png new file mode 100644 index 00000000..d373e2e1 Binary files /dev/null and b/packages/pinball_components/assets/images/score/five-thousand.png differ diff --git a/packages/pinball_components/assets/images/score/one-million.png b/packages/pinball_components/assets/images/score/one-million.png new file mode 100644 index 00000000..5c7ec15b Binary files /dev/null and b/packages/pinball_components/assets/images/score/one-million.png differ diff --git a/packages/pinball_components/assets/images/score/twenty-thousand.png b/packages/pinball_components/assets/images/score/twenty-thousand.png new file mode 100644 index 00000000..2f9bfd57 Binary files /dev/null and b/packages/pinball_components/assets/images/score/twenty-thousand.png differ diff --git a/packages/pinball_components/assets/images/score/two-hundred-thousand.png b/packages/pinball_components/assets/images/score/two-hundred-thousand.png new file mode 100644 index 00000000..a6f19db4 Binary files /dev/null and b/packages/pinball_components/assets/images/score/two-hundred-thousand.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index 4388ced6..3e2faad1 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -28,9 +28,11 @@ class $AssetsImagesGen { $AssetsImagesKickerGen get kicker => const $AssetsImagesKickerGen(); $AssetsImagesLaunchRampGen get launchRamp => const $AssetsImagesLaunchRampGen(); + $AssetsImagesMultiballGen get multiball => const $AssetsImagesMultiballGen(); $AssetsImagesMultiplierGen get multiplier => const $AssetsImagesMultiplierGen(); $AssetsImagesPlungerGen get plunger => const $AssetsImagesPlungerGen(); + $AssetsImagesScoreGen get score => const $AssetsImagesScoreGen(); $AssetsImagesSignpostGen get signpost => const $AssetsImagesSignpostGen(); $AssetsImagesSlingshotGen get slingshot => const $AssetsImagesSlingshotGen(); $AssetsImagesSparkyGen get sparky => const $AssetsImagesSparkyGen(); @@ -179,6 +181,18 @@ class $AssetsImagesLaunchRampGen { const AssetGenImage('assets/images/launch_ramp/ramp.png'); } +class $AssetsImagesMultiballGen { + const $AssetsImagesMultiballGen(); + + /// File path: assets/images/multiball/dimmed.png + AssetGenImage get dimmed => + const AssetGenImage('assets/images/multiball/dimmed.png'); + + /// File path: assets/images/multiball/lit.png + AssetGenImage get lit => + const AssetGenImage('assets/images/multiball/lit.png'); +} + class $AssetsImagesMultiplierGen { const $AssetsImagesMultiplierGen(); @@ -201,6 +215,26 @@ class $AssetsImagesPlungerGen { const AssetGenImage('assets/images/plunger/rocket.png'); } +class $AssetsImagesScoreGen { + const $AssetsImagesScoreGen(); + + /// File path: assets/images/score/five-thousand.png + AssetGenImage get fiveThousand => + const AssetGenImage('assets/images/score/five-thousand.png'); + + /// File path: assets/images/score/one-million.png + AssetGenImage get oneMillion => + const AssetGenImage('assets/images/score/one-million.png'); + + /// File path: assets/images/score/twenty-thousand.png + AssetGenImage get twentyThousand => + const AssetGenImage('assets/images/score/twenty-thousand.png'); + + /// File path: assets/images/score/two-hundred-thousand.png + AssetGenImage get twoHundredThousand => + const AssetGenImage('assets/images/score/two-hundred-thousand.png'); +} + class $AssetsImagesSignpostGen { const $AssetsImagesSignpostGen(); diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 43ba302f..5b661691 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -21,10 +21,11 @@ export 'kicker/kicker.dart'; export 'launch_ramp.dart'; export 'layer.dart'; export 'layer_sensor.dart'; +export 'multiball/multiball.dart'; export 'multiplier/multiplier.dart'; export 'plunger.dart'; export 'rocket.dart'; -export 'score_text.dart'; +export 'score_component.dart'; export 'shapes/shapes.dart'; export 'signpost.dart'; export 'slingshot.dart'; diff --git a/packages/pinball_components/lib/src/components/multiball/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/multiball/behaviors/behaviors.dart new file mode 100644 index 00000000..052b4a4e --- /dev/null +++ b/packages/pinball_components/lib/src/components/multiball/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'multiball_blinking_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/multiball/behaviors/multiball_blinking_behavior.dart b/packages/pinball_components/lib/src/components/multiball/behaviors/multiball_blinking_behavior.dart new file mode 100644 index 00000000..48c90552 --- /dev/null +++ b/packages/pinball_components/lib/src/components/multiball/behaviors/multiball_blinking_behavior.dart @@ -0,0 +1,78 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template multiball_blinking_behavior} +/// Makes a [Multiball] blink back to [MultiballLightState.lit] when +/// [MultiballLightState.dimmed]. +/// {@endtemplate} +class MultiballBlinkingBehavior extends TimerComponent + with ParentIsA { + /// {@macro multiball_blinking_behavior} + MultiballBlinkingBehavior() : super(period: 0.1); + + final _maxBlinks = 10; + + int _blinksCounter = 0; + + bool _isAnimating = false; + + void _onNewState(MultiballState state) { + final animationEnabled = + state.animationState == MultiballAnimationState.blinking; + final canBlink = _blinksCounter < _maxBlinks; + + if (animationEnabled && canBlink) { + _start(); + } else { + _stop(); + } + } + + void _start() { + if (!_isAnimating) { + _isAnimating = true; + timer + ..reset() + ..start(); + _animate(); + } + } + + void _animate() { + parent.bloc.onBlink(); + _blinksCounter++; + } + + void _stop() { + if (_isAnimating) { + _isAnimating = false; + timer.stop(); + _blinksCounter = 0; + parent.bloc.onStop(); + } + } + + @override + Future onLoad() async { + await super.onLoad(); + parent.bloc.stream.listen(_onNewState); + } + + @override + void onTick() { + super.onTick(); + if (!_isAnimating) { + timer.stop(); + } else { + if (_blinksCounter < _maxBlinks) { + _animate(); + timer + ..reset() + ..start(); + } else { + timer.stop(); + } + } + } +} diff --git a/packages/pinball_components/lib/src/components/multiball/cubit/multiball_cubit.dart b/packages/pinball_components/lib/src/components/multiball/cubit/multiball_cubit.dart new file mode 100644 index 00000000..9d943c9d --- /dev/null +++ b/packages/pinball_components/lib/src/components/multiball/cubit/multiball_cubit.dart @@ -0,0 +1,37 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'multiball_state.dart'; + +class MultiballCubit extends Cubit { + MultiballCubit() : super(const MultiballState.initial()); + + void onAnimate() { + emit( + state.copyWith(animationState: MultiballAnimationState.blinking), + ); + } + + void onStop() { + emit( + state.copyWith(animationState: MultiballAnimationState.idle), + ); + } + + void onBlink() { + switch (state.lightState) { + case MultiballLightState.lit: + emit( + state.copyWith(lightState: MultiballLightState.dimmed), + ); + break; + case MultiballLightState.dimmed: + emit( + state.copyWith(lightState: MultiballLightState.lit), + ); + break; + } + } +} diff --git a/packages/pinball_components/lib/src/components/multiball/cubit/multiball_state.dart b/packages/pinball_components/lib/src/components/multiball/cubit/multiball_state.dart new file mode 100644 index 00000000..bbc66fd5 --- /dev/null +++ b/packages/pinball_components/lib/src/components/multiball/cubit/multiball_state.dart @@ -0,0 +1,44 @@ +// ignore_for_file: comment_references, public_member_api_docs + +part of 'multiball_cubit.dart'; + +/// Indicates the different sprite states for [MultiballSpriteGroupComponent]. +enum MultiballLightState { + lit, + dimmed, +} + +// Indicates if the blinking animation is running. +enum MultiballAnimationState { + idle, + blinking, +} + +class MultiballState extends Equatable { + const MultiballState({ + required this.lightState, + required this.animationState, + }); + + const MultiballState.initial() + : this( + lightState: MultiballLightState.dimmed, + animationState: MultiballAnimationState.idle, + ); + + final MultiballLightState lightState; + final MultiballAnimationState animationState; + + MultiballState copyWith({ + MultiballLightState? lightState, + MultiballAnimationState? animationState, + }) { + return MultiballState( + lightState: lightState ?? this.lightState, + animationState: animationState ?? this.animationState, + ); + } + + @override + List get props => [lightState, animationState]; +} diff --git a/packages/pinball_components/lib/src/components/multiball/multiball.dart b/packages/pinball_components/lib/src/components/multiball/multiball.dart new file mode 100644 index 00000000..ca348604 --- /dev/null +++ b/packages/pinball_components/lib/src/components/multiball/multiball.dart @@ -0,0 +1,138 @@ +import 'dart:math' as math; +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/gen/assets.gen.dart'; +import 'package:pinball_components/src/components/multiball/behaviors/behaviors.dart'; +import 'package:pinball_components/src/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'cubit/multiball_cubit.dart'; + +/// {@template multiball} +/// A [Component] for the multiball lighting decals on the board. +/// {@endtemplate} +class Multiball extends Component { + /// {@macro multiball} + Multiball._({ + required Vector2 position, + double rotation = 0, + Iterable? children, + required this.bloc, + }) : super( + children: [ + MultiballBlinkingBehavior(), + MultiballSpriteGroupComponent( + position: position, + litAssetPath: Assets.images.multiball.lit.keyName, + dimmedAssetPath: Assets.images.multiball.dimmed.keyName, + rotation: rotation, + state: bloc.state.lightState, + ), + ...?children, + ], + ); + + /// {@macro multiball} + Multiball.a({ + Iterable? children, + }) : this._( + position: Vector2(-23, 7.5), + rotation: -24 * math.pi / 180, + bloc: MultiballCubit(), + children: children, + ); + + /// {@macro multiball} + Multiball.b({ + Iterable? children, + }) : this._( + position: Vector2(-7.2, -6.2), + rotation: -5 * math.pi / 180, + bloc: MultiballCubit(), + children: children, + ); + + /// {@macro multiball} + Multiball.c({ + Iterable? children, + }) : this._( + position: Vector2(-0.7, -9.3), + rotation: 2.7 * math.pi / 180, + bloc: MultiballCubit(), + children: children, + ); + + /// {@macro multiball} + Multiball.d({ + Iterable? children, + }) : this._( + position: Vector2(15, 7), + rotation: 24 * math.pi / 180, + bloc: MultiballCubit(), + children: children, + ); + + /// Creates an [Multiball] without any children. + /// + /// This can be used for testing [Multiball]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + Multiball.test({ + required this.bloc, + }); + + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final MultiballCubit bloc; + + @override + void onRemove() { + bloc.close(); + super.onRemove(); + } +} + +/// {@template multiball_sprite_group_component} +/// A [SpriteGroupComponent] for the multiball over the board. +/// {@endtemplate} +@visibleForTesting +class MultiballSpriteGroupComponent + extends SpriteGroupComponent + with HasGameRef, ParentIsA { + /// {@macro multiball_sprite_group_component} + MultiballSpriteGroupComponent({ + required Vector2 position, + required String litAssetPath, + required String dimmedAssetPath, + required double rotation, + required MultiballLightState state, + }) : _litAssetPath = litAssetPath, + _dimmedAssetPath = dimmedAssetPath, + super( + anchor: Anchor.center, + position: position, + angle: rotation, + current: state, + ); + + final String _litAssetPath; + final String _dimmedAssetPath; + + @override + Future onLoad() async { + await super.onLoad(); + parent.bloc.stream.listen((state) => current = state.lightState); + + final sprites = { + MultiballLightState.lit: Sprite( + gameRef.images.fromCache(_litAssetPath), + ), + MultiballLightState.dimmed: + Sprite(gameRef.images.fromCache(_dimmedAssetPath)), + }; + this.sprites = sprites; + size = sprites[current]!.originalSize / 10; + } +} diff --git a/packages/pinball_components/lib/src/components/plunger.dart b/packages/pinball_components/lib/src/components/plunger.dart index fa81c783..79b370a0 100644 --- a/packages/pinball_components/lib/src/components/plunger.dart +++ b/packages/pinball_components/lib/src/components/plunger.dart @@ -68,6 +68,14 @@ class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex { return body; } + var _pullingDownTime = 0.0; + + /// Pulls the plunger down for the given amount of [seconds]. + // ignore: use_setters_to_change_properties + void pullFor(double seconds) { + _pullingDownTime = seconds; + } + /// Set a constant downward velocity on the [Plunger]. void pull() { body.linearVelocity = Vector2(0, 7); @@ -79,11 +87,26 @@ class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex { /// The velocity's magnitude depends on how far the [Plunger] has been pulled /// from its original [initialPosition]. void release() { + _pullingDownTime = 0; final velocity = (initialPosition.y - body.position.y) * 11; body.linearVelocity = Vector2(0, velocity); _spriteComponent.release(); } + @override + void update(double dt) { + // Ensure that we only pull or release when the time is greater than zero. + if (_pullingDownTime > 0) { + _pullingDownTime -= dt; + if (_pullingDownTime <= 0) { + release(); + } else { + pull(); + } + } + super.update(dt); + } + /// Anchors the [Plunger] to the [PrismaticJoint] that controls its vertical /// motion. Future _anchorToJoint() async { diff --git a/packages/pinball_components/lib/src/components/score_component.dart b/packages/pinball_components/lib/src/components/score_component.dart new file mode 100644 index 00000000..12d198cb --- /dev/null +++ b/packages/pinball_components/lib/src/components/score_component.dart @@ -0,0 +1,92 @@ +// ignore_for_file: public_member_api_docs + +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +enum Points { + fiveThousand, + twentyThousand, + twoHundredThousand, + oneMillion, +} + +/// {@template score_component} +/// A [ScoreComponent] that spawns at a given [position] with a moving +/// animation. +/// {@endtemplate} +class ScoreComponent extends SpriteComponent with HasGameRef, ZIndex { + /// {@macro score_component} + ScoreComponent({ + required this.points, + required Vector2 position, + }) : super( + position: position, + anchor: Anchor.center, + ) { + zIndex = ZIndexes.score; + } + + late final Effect _effect; + + late Points points; + + @override + Future onLoad() async { + await super.onLoad(); + final sprite = Sprite( + gameRef.images.fromCache(points.asset), + ); + this.sprite = sprite; + size = sprite.originalSize / 55; + + await add( + _effect = MoveEffect.by( + Vector2(0, -5), + EffectController(duration: 1), + ), + ); + } + + @override + void update(double dt) { + super.update(dt); + + if (_effect.controller.completed) { + removeFromParent(); + } + } +} + +extension PointsX on Points { + int get value { + switch (this) { + case Points.fiveThousand: + return 5000; + case Points.twentyThousand: + return 20000; + case Points.twoHundredThousand: + return 200000; + case Points.oneMillion: + return 1000000; + } + } +} + +extension on Points { + String get asset { + switch (this) { + case Points.fiveThousand: + return Assets.images.score.fiveThousand.keyName; + case Points.twentyThousand: + return Assets.images.score.twentyThousand.keyName; + case Points.twoHundredThousand: + return Assets.images.score.twoHundredThousand.keyName; + case Points.oneMillion: + return Assets.images.score.oneMillion.keyName; + } + } +} diff --git a/packages/pinball_components/lib/src/components/score_text.dart b/packages/pinball_components/lib/src/components/score_text.dart deleted file mode 100644 index 6dcba4b1..00000000 --- a/packages/pinball_components/lib/src/components/score_text.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:async'; - -import 'package:flame/components.dart'; -import 'package:flame/effects.dart'; -import 'package:flutter/material.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template score_text} -/// A [TextComponent] that spawns at a given [position] with a moving animation. -/// {@endtemplate} -class ScoreText extends TextComponent with ZIndex { - /// {@macro score_text} - ScoreText({ - required String text, - required Vector2 position, - this.color = Colors.black, - }) : super( - text: text, - position: position, - anchor: Anchor.center, - ) { - zIndex = ZIndexes.scoreText; - } - - late final Effect _effect; - - /// The [text]'s [Color]. - final Color color; - - @override - Future onLoad() async { - textRenderer = TextPaint( - style: TextStyle( - fontFamily: PinballFonts.pixeloidMono, - color: color, - fontSize: 4, - ), - ); - - await add( - _effect = MoveEffect.by( - Vector2(0, -5), - EffectController(duration: 1), - ), - ); - } - - @override - void update(double dt) { - super.update(dt); - - if (_effect.controller.completed) { - removeFromParent(); - } - } -} diff --git a/packages/pinball_components/lib/src/components/z_indexes.dart b/packages/pinball_components/lib/src/components/z_indexes.dart index 04dd02c7..440bd1fe 100644 --- a/packages/pinball_components/lib/src/components/z_indexes.dart +++ b/packages/pinball_components/lib/src/components/z_indexes.dart @@ -101,10 +101,10 @@ abstract class ZIndexes { static const androidBumper = _above + ballOnBoard; - // Score Text + // Score - static const scoreText = _above + spaceshipRampForegroundRailing; + static const score = _above + spaceshipRampForegroundRailing; // Debug information - static const debugInfo = _above + scoreText; + static const debugInfo = _above + score; } diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 4ca7f28e..61e62386 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -81,11 +81,13 @@ flutter: - assets/images/google_word/letter5/ - assets/images/google_word/letter6/ - assets/images/signpost/ + - assets/images/multiball/ - assets/images/multiplier/x2/ - assets/images/multiplier/x3/ - assets/images/multiplier/x4/ - assets/images/multiplier/x5/ - assets/images/multiplier/x6/ + - assets/images/score/ flutter_gen: line_length: 80 diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index c123c2d9..9fdee65a 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -24,9 +24,10 @@ void main() { addBoundariesStories(dashbook); addGoogleWordStories(dashbook); addLaunchRampStories(dashbook); - addScoreTextStories(dashbook); + addScoreStories(dashbook); addBackboardStories(dashbook); addDinoWallStories(dashbook); + addMultiballStories(dashbook); addMultipliersStories(dashbook); runApp(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 index 639a4b57..ce14d7b6 100644 --- 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 @@ -1,6 +1,5 @@ import 'package:flame/input.dart'; -import 'package:flutter/material.dart'; -import 'package:pinball_components/pinball_components.dart' hide Assets; +import 'package:pinball_components/pinball_components.dart' as components; import 'package:pinball_theme/pinball_theme.dart'; import 'package:sandbox/common/common.dart'; @@ -8,7 +7,13 @@ class BackboardGameOverGame extends AssetsGame with HasKeyboardHandlerComponents { BackboardGameOverGame(this.score, this.character) : super( - imagesFileNames: characterIconPaths.values.toList(), + 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 = ''' @@ -30,21 +35,23 @@ class BackboardGameOverGame extends AssetsGame @override Future onLoad() async { + await super.onLoad(); + camera ..followVector2(Vector2.zero()) ..zoom = 5; await add( - Backboard.gameOver( + components.Backboard.gameOver( position: Vector2(0, 20), score: score, characterIconPath: characterIconPaths[character]!, onSubmit: (initials) { add( - ScoreText( - text: 'User $initials made $score', + components.ScoreComponent( + points: components.Points.values + .firstWhere((element) => element.value == score), position: Vector2(0, 50), - color: Colors.pink, ), ); }, diff --git a/packages/pinball_components/sandbox/lib/stories/backboard/stories.dart b/packages/pinball_components/sandbox/lib/stories/backboard/stories.dart index b8c85d10..9e83c7c4 100644 --- a/packages/pinball_components/sandbox/lib/stories/backboard/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/backboard/stories.dart @@ -1,4 +1,5 @@ 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'; @@ -14,7 +15,11 @@ void addBackboardStories(Dashbook dashbook) { title: 'Game over', description: BackboardGameOverGame.description, gameBuilder: (context) => BackboardGameOverGame( - context.numberProperty('Score', 9000000000).toInt(), + context.listProperty( + 'Score', + Points.values.first.value, + Points.values.map((score) => score.value).toList(), + ), context.listProperty( 'Character', BackboardGameOverGame.characterIconPaths.keys.first, diff --git a/packages/pinball_components/sandbox/lib/stories/multiball/multiball_game.dart b/packages/pinball_components/sandbox/lib/stories/multiball/multiball_game.dart new file mode 100644 index 00000000..83b53785 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/multiball/multiball_game.dart @@ -0,0 +1,56 @@ +import 'package:flame/input.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/stories/ball/basic_ball_game.dart'; + +class MultiballGame extends BallGame with KeyboardEvents { + MultiballGame() + : super( + imagesFileNames: [ + Assets.images.multiball.lit.keyName, + Assets.images.multiball.dimmed.keyName, + ], + ); + + static const description = ''' + Shows how the Multiball are rendered. + + - Tap anywhere on the screen to spawn a ball into the game. + - Press space bar to animate multiballs. +'''; + + final List multiballs = [ + Multiball.a(), + Multiball.b(), + Multiball.c(), + Multiball.d(), + ]; + + @override + Future onLoad() async { + await super.onLoad(); + + camera.followVector2(Vector2.zero()); + + await addAll(multiballs); + await traceAllBodies(); + } + + @override + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + if (event is RawKeyDownEvent && + event.logicalKey == LogicalKeyboardKey.space) { + for (final multiball in multiballs) { + multiball.bloc.onBlink(); + } + + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/multiball/stories.dart b/packages/pinball_components/sandbox/lib/stories/multiball/stories.dart new file mode 100644 index 00000000..6993ed92 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/multiball/stories.dart @@ -0,0 +1,11 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/multiball/multiball_game.dart'; + +void addMultiballStories(Dashbook dashbook) { + dashbook.storiesOf('Multiball').addGame( + title: 'Assets', + description: MultiballGame.description, + gameBuilder: (_) => MultiballGame(), + ); +} diff --git a/packages/pinball_components/sandbox/lib/stories/score/score_game.dart b/packages/pinball_components/sandbox/lib/stories/score/score_game.dart new file mode 100644 index 00000000..4bde5018 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/score/score_game.dart @@ -0,0 +1,44 @@ +import 'dart:math'; + +import 'package:flame/input.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; + +class ScoreGame extends AssetsGame with TapDetector { + ScoreGame() + : super( + imagesFileNames: [ + Assets.images.score.fiveThousand.keyName, + Assets.images.score.twentyThousand.keyName, + Assets.images.score.twoHundredThousand.keyName, + Assets.images.score.oneMillion.keyName, + ], + ); + + static const description = ''' + Simple game to show how score component works, + + - Tap anywhere on the screen to spawn an image on the given location. +'''; + + final random = Random(); + + @override + Future onLoad() async { + await super.onLoad(); + camera.followVector2(Vector2.zero()); + } + + @override + void onTapUp(TapUpInfo info) { + final index = random.nextInt(Points.values.length); + final score = Points.values[index]; + + add( + ScoreComponent( + points: score, + position: info.eventPosition.game..multiply(Vector2(1, -1)), + ), + ); + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/score/stories.dart b/packages/pinball_components/sandbox/lib/stories/score/stories.dart new file mode 100644 index 00000000..9c1d3c62 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/score/stories.dart @@ -0,0 +1,11 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/score/score_game.dart'; + +void addScoreStories(Dashbook dashbook) { + dashbook.storiesOf('Score').addGame( + title: 'Basic', + description: ScoreGame.description, + gameBuilder: (_) => ScoreGame(), + ); +} diff --git a/packages/pinball_components/sandbox/lib/stories/score_text/score_text_game.dart b/packages/pinball_components/sandbox/lib/stories/score_text/score_text_game.dart deleted file mode 100644 index aa776405..00000000 --- a/packages/pinball_components/sandbox/lib/stories/score_text/score_text_game.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:math'; - -import 'package:flame/input.dart'; -import 'package:flutter/material.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:sandbox/common/common.dart'; - -class ScoreTextGame extends AssetsGame with TapDetector { - static const description = ''' - Simple game to show how score text works, - - - Tap anywhere on the screen to spawn an text on the given location. -'''; - - final random = Random(); - - @override - Future onLoad() async { - camera.followVector2(Vector2.zero()); - } - - @override - void onTapUp(TapUpInfo info) { - add( - ScoreText( - text: random.nextInt(100000).toString(), - color: Colors.white, - position: info.eventPosition.game..multiply(Vector2(1, -1)), - ), - ); - } -} diff --git a/packages/pinball_components/sandbox/lib/stories/score_text/stories.dart b/packages/pinball_components/sandbox/lib/stories/score_text/stories.dart deleted file mode 100644 index c4899a27..00000000 --- a/packages/pinball_components/sandbox/lib/stories/score_text/stories.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:dashbook/dashbook.dart'; -import 'package:sandbox/common/common.dart'; -import 'package:sandbox/stories/score_text/score_text_game.dart'; - -void addScoreTextStories(Dashbook dashbook) { - dashbook.storiesOf('ScoreText').addGame( - title: 'Basic', - description: ScoreTextGame.description, - gameBuilder: (_) => ScoreTextGame(), - ); -} diff --git a/packages/pinball_components/sandbox/lib/stories/stories.dart b/packages/pinball_components/sandbox/lib/stories/stories.dart index d8641b9c..8cdd38b1 100644 --- a/packages/pinball_components/sandbox/lib/stories/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/stories.dart @@ -10,8 +10,9 @@ export 'flutter_forest/stories.dart'; export 'google_word/stories.dart'; export 'launch_ramp/stories.dart'; export 'layer/stories.dart'; +export 'multiball/stories.dart'; export 'multipliers/stories.dart'; export 'plunger/stories.dart'; -export 'score_text/stories.dart'; +export 'score/stories.dart'; export 'slingshot/stories.dart'; export 'sparky_scorch/stories.dart'; diff --git a/packages/pinball_components/test/helpers/mocks.dart b/packages/pinball_components/test/helpers/mocks.dart index ab867e3b..99959e03 100644 --- a/packages/pinball_components/test/helpers/mocks.dart +++ b/packages/pinball_components/test/helpers/mocks.dart @@ -25,6 +25,8 @@ class MockSparkyBumperCubit extends Mock implements SparkyBumperCubit {} class MockDashNestBumperCubit extends Mock implements DashNestBumperCubit {} +class MockMultiballCubit extends Mock implements MultiballCubit {} + class MockMultiplierCubit extends Mock implements MultiplierCubit {} class MockChromeDinoCubit extends Mock implements ChromeDinoCubit {} diff --git a/packages/pinball_components/test/src/components/golden/score/1m.png b/packages/pinball_components/test/src/components/golden/score/1m.png new file mode 100644 index 00000000..bb2f5631 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/score/1m.png differ diff --git a/packages/pinball_components/test/src/components/golden/score/200k.png b/packages/pinball_components/test/src/components/golden/score/200k.png new file mode 100644 index 00000000..c25d116b Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/score/200k.png differ diff --git a/packages/pinball_components/test/src/components/golden/score/20k.png b/packages/pinball_components/test/src/components/golden/score/20k.png new file mode 100644 index 00000000..2a4446c3 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/score/20k.png differ diff --git a/packages/pinball_components/test/src/components/golden/score/5k.png b/packages/pinball_components/test/src/components/golden/score/5k.png new file mode 100644 index 00000000..8f2a7973 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/score/5k.png differ diff --git a/packages/pinball_components/test/src/components/multiball/behaviors/multiball_blinking_behavior_test.dart b/packages/pinball_components/test/src/components/multiball/behaviors/multiball_blinking_behavior_test.dart new file mode 100644 index 00000000..2b3885f9 --- /dev/null +++ b/packages/pinball_components/test/src/components/multiball/behaviors/multiball_blinking_behavior_test.dart @@ -0,0 +1,158 @@ +// ignore_for_file: prefer_const_constructors, cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/multiball/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'MultiballBlinkingBehavior', + () { + flameTester.testGameWidget( + 'calls onBlink every 0.1 seconds when animation state is animated', + setUp: (game, tester) async { + final behavior = MultiballBlinkingBehavior(); + final bloc = MockMultiballCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: MultiballState.initial(), + ); + + final multiball = Multiball.test(bloc: bloc); + await multiball.add(behavior); + await game.ensureAdd(multiball); + + streamController.add( + MultiballState( + animationState: MultiballAnimationState.blinking, + lightState: MultiballLightState.lit, + ), + ); + await tester.pump(); + game.update(0); + + verify(bloc.onBlink).called(1); + + await tester.pump(); + game.update(0.1); + + await streamController.close(); + verify(bloc.onBlink).called(1); + }, + ); + + flameTester.testGameWidget( + 'calls onStop when animation state is stopped', + setUp: (game, tester) async { + final behavior = MultiballBlinkingBehavior(); + final bloc = MockMultiballCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: MultiballState.initial(), + ); + when(bloc.onBlink).thenAnswer((_) async {}); + + final multiball = Multiball.test(bloc: bloc); + await multiball.add(behavior); + await game.ensureAdd(multiball); + + streamController.add( + MultiballState( + animationState: MultiballAnimationState.blinking, + lightState: MultiballLightState.lit, + ), + ); + await tester.pump(); + + streamController.add( + MultiballState( + animationState: MultiballAnimationState.idle, + lightState: MultiballLightState.lit, + ), + ); + + await streamController.close(); + verify(bloc.onStop).called(1); + }, + ); + + flameTester.testGameWidget( + 'onTick stops when there is no animation', + setUp: (game, tester) async { + final behavior = MultiballBlinkingBehavior(); + final bloc = MockMultiballCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: MultiballState.initial(), + ); + when(bloc.onBlink).thenAnswer((_) async {}); + + final multiball = Multiball.test(bloc: bloc); + await multiball.add(behavior); + await game.ensureAdd(multiball); + + streamController.add( + MultiballState( + animationState: MultiballAnimationState.idle, + lightState: MultiballLightState.lit, + ), + ); + await tester.pump(); + + behavior.onTick(); + + expect(behavior.timer.isRunning(), false); + }, + ); + + flameTester.testGameWidget( + 'onTick stops after 10 blinks repetitions', + setUp: (game, tester) async { + final behavior = MultiballBlinkingBehavior(); + final bloc = MockMultiballCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: MultiballState.initial(), + ); + when(bloc.onBlink).thenAnswer((_) async {}); + + final multiball = Multiball.test(bloc: bloc); + await multiball.add(behavior); + await game.ensureAdd(multiball); + + streamController.add( + MultiballState( + animationState: MultiballAnimationState.blinking, + lightState: MultiballLightState.dimmed, + ), + ); + await tester.pump(); + + for (var i = 0; i < 10; i++) { + behavior.onTick(); + } + + expect(behavior.timer.isRunning(), false); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/multiball/cubit/multiball_cubit_test.dart b/packages/pinball_components/test/src/components/multiball/cubit/multiball_cubit_test.dart new file mode 100644 index 00000000..2fcb5ccc --- /dev/null +++ b/packages/pinball_components/test/src/components/multiball/cubit/multiball_cubit_test.dart @@ -0,0 +1,67 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group( + 'MultiballCubit', + () { + blocTest( + 'onAnimate emits animationState [animate]', + build: MultiballCubit.new, + act: (bloc) => bloc.onAnimate(), + expect: () => [ + isA() + ..having( + (state) => state.animationState, + 'animationState', + MultiballAnimationState.blinking, + ) + ], + ); + + blocTest( + 'onStop emits animationState [stopped]', + build: MultiballCubit.new, + act: (bloc) => bloc.onStop(), + expect: () => [ + isA() + ..having( + (state) => state.animationState, + 'animationState', + MultiballAnimationState.idle, + ) + ], + ); + + blocTest( + 'onBlink emits lightState [lit, dimmed, lit]', + build: MultiballCubit.new, + act: (bloc) => bloc + ..onBlink() + ..onBlink() + ..onBlink(), + expect: () => [ + isA() + ..having( + (state) => state.lightState, + 'lightState', + MultiballLightState.lit, + ), + isA() + ..having( + (state) => state.lightState, + 'lightState', + MultiballLightState.dimmed, + ), + isA() + ..having( + (state) => state.lightState, + 'lightState', + MultiballLightState.lit, + ) + ], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/multiball/cubit/multiball_state_test.dart b/packages/pinball_components/test/src/components/multiball/cubit/multiball_state_test.dart new file mode 100644 index 00000000..69789be9 --- /dev/null +++ b/packages/pinball_components/test/src/components/multiball/cubit/multiball_state_test.dart @@ -0,0 +1,76 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/src/pinball_components.dart'; + +void main() { + group('MultiballState', () { + test('supports value equality', () { + expect( + MultiballState( + animationState: MultiballAnimationState.idle, + lightState: MultiballLightState.dimmed, + ), + equals( + MultiballState( + animationState: MultiballAnimationState.idle, + lightState: MultiballLightState.dimmed, + ), + ), + ); + }); + + group('constructor', () { + test('can be instantiated', () { + expect( + MultiballState( + animationState: MultiballAnimationState.idle, + lightState: MultiballLightState.dimmed, + ), + isNotNull, + ); + }); + }); + + group('copyWith', () { + test( + 'copies correctly ' + 'when no argument specified', + () { + final multiballState = MultiballState( + animationState: MultiballAnimationState.idle, + lightState: MultiballLightState.dimmed, + ); + expect( + multiballState.copyWith(), + equals(multiballState), + ); + }, + ); + + test( + 'copies correctly ' + 'when all arguments specified', + () { + final multiballState = MultiballState( + animationState: MultiballAnimationState.idle, + lightState: MultiballLightState.dimmed, + ); + final otherMultiballState = MultiballState( + animationState: MultiballAnimationState.blinking, + lightState: MultiballLightState.lit, + ); + expect(multiballState, isNot(equals(otherMultiballState))); + + expect( + multiballState.copyWith( + animationState: MultiballAnimationState.blinking, + lightState: MultiballLightState.lit, + ), + equals(otherMultiballState), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/multiball/multiball_test.dart b/packages/pinball_components/test/src/components/multiball/multiball_test.dart new file mode 100644 index 00000000..9b1e0e2f --- /dev/null +++ b/packages/pinball_components/test/src/components/multiball/multiball_test.dart @@ -0,0 +1,90 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/multiball/behaviors/behaviors.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.multiball.lit.keyName, + Assets.images.multiball.dimmed.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + group('Multiball', () { + group('loads correctly', () { + flameTester.test('"a"', (game) async { + final multiball = Multiball.a(); + await game.ensureAdd(multiball); + + expect(game.contains(multiball), isTrue); + }); + + flameTester.test('"b"', (game) async { + final multiball = Multiball.b(); + await game.ensureAdd(multiball); + expect(game.contains(multiball), isTrue); + }); + + flameTester.test('"c"', (game) async { + final multiball = Multiball.c(); + await game.ensureAdd(multiball); + + expect(game.contains(multiball), isTrue); + }); + + flameTester.test('"d"', (game) async { + final multiball = Multiball.d(); + await game.ensureAdd(multiball); + expect(game.contains(multiball), isTrue); + }); + }); + + flameTester.test( + 'closes bloc when removed', + (game) async { + final bloc = MockMultiballCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: MultiballLightState.dimmed, + ); + when(bloc.close).thenAnswer((_) async {}); + final multiball = Multiball.test(bloc: bloc); + + await game.ensureAdd(multiball); + game.remove(multiball); + await game.ready(); + + verify(bloc.close).called(1); + }, + ); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final multiball = Multiball.a( + children: [component], + ); + await game.ensureAdd(multiball); + expect(multiball.children, contains(component)); + }); + + flameTester.test('a MultiballBlinkingBehavior', (game) async { + final multiball = Multiball.a(); + await game.ensureAdd(multiball); + expect( + multiball.children.whereType().single, + isNotNull, + ); + }); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger_test.dart b/packages/pinball_components/test/src/components/plunger_test.dart index eafc15d5..abb42d68 100644 --- a/packages/pinball_components/test/src/components/plunger_test.dart +++ b/packages/pinball_components/test/src/components/plunger_test.dart @@ -121,6 +121,33 @@ void main() { ); }); + group('pullFor', () { + late Plunger plunger; + + setUp(() { + plunger = Plunger( + compressionDistance: compressionDistance, + ); + }); + + flameTester.testGameWidget( + 'moves downwards for given period when pullFor is called', + setUp: (game, tester) async { + await game.ensureAdd(plunger); + }, + verify: (game, tester) async { + plunger.pullFor(2); + game.update(0); + + expect(plunger.body.linearVelocity.y, isPositive); + + await tester.pump(const Duration(seconds: 2)); + + expect(plunger.body.linearVelocity.y, isZero); + }, + ); + }); + group('pull', () { late Plunger plunger; diff --git a/packages/pinball_components/test/src/components/score_component_test.dart b/packages/pinball_components/test/src/components/score_component_test.dart new file mode 100644 index 00000000..69688874 --- /dev/null +++ b/packages/pinball_components/test/src/components/score_component_test.dart @@ -0,0 +1,202 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.score.fiveThousand.keyName, + Assets.images.score.twentyThousand.keyName, + Assets.images.score.twoHundredThousand.keyName, + Assets.images.score.oneMillion.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + group('ScoreComponent', () { + flameTester.testGameWidget( + 'loads correctly', + setUp: (game, tester) async { + await game.images.loadAll(assets); + game.camera.followVector2(Vector2.zero()); + await game.ensureAdd( + ScoreComponent( + points: Points.oneMillion, + position: Vector2.zero(), + ), + ); + }, + verify: (game, tester) async { + final texts = game.descendants().whereType().length; + expect(texts, equals(1)); + }, + ); + + flameTester.testGameWidget( + 'has a movement effect', + setUp: (game, tester) async { + await game.images.loadAll(assets); + game.camera.followVector2(Vector2.zero()); + await game.ensureAdd( + ScoreComponent( + points: Points.oneMillion, + position: Vector2.zero(), + ), + ); + + game.update(0.5); + await tester.pump(); + }, + verify: (game, tester) async { + final text = game.descendants().whereType().first; + expect(text.firstChild(), isNotNull); + }, + ); + + flameTester.testGameWidget( + 'is removed once finished', + setUp: (game, tester) async { + await game.images.loadAll(assets); + game.camera.followVector2(Vector2.zero()); + await game.ensureAdd( + ScoreComponent( + points: Points.oneMillion, + position: Vector2.zero(), + ), + ); + + game.update(1); + game.update(0); // Ensure all component removals + await tester.pump(); + }, + verify: (game, tester) async { + expect(game.children.length, equals(0)); + }, + ); + + group('renders correctly', () { + flameTester.testGameWidget( + '5000 points', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd( + ScoreComponent( + points: Points.fiveThousand, + position: Vector2.zero(), + ), + ); + + game.camera + ..followVector2(Vector2.zero()) + ..zoom = 8; + + await tester.pump(); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/score/5k.png'), + ); + }, + ); + + flameTester.testGameWidget( + '20000 points', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd( + ScoreComponent( + points: Points.twentyThousand, + position: Vector2.zero(), + ), + ); + + game.camera + ..followVector2(Vector2.zero()) + ..zoom = 8; + + await tester.pump(); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/score/20k.png'), + ); + }, + ); + + flameTester.testGameWidget( + '200000 points', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd( + ScoreComponent( + points: Points.twoHundredThousand, + position: Vector2.zero(), + ), + ); + + game.camera + ..followVector2(Vector2.zero()) + ..zoom = 8; + + await tester.pump(); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/score/200k.png'), + ); + }, + ); + + flameTester.testGameWidget( + '1000000 points', + setUp: (game, tester) async { + await game.images.loadAll(assets); + await game.ensureAdd( + ScoreComponent( + points: Points.oneMillion, + position: Vector2.zero(), + ), + ); + + game.camera + ..followVector2(Vector2.zero()) + ..zoom = 8; + + await tester.pump(); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/score/1m.png'), + ); + }, + ); + }); + }); + + group('PointsX', () { + test('5k value return 5000', () { + expect(Points.fiveThousand.value, 5000); + }); + + test('20k value return 20000', () { + expect(Points.twentyThousand.value, 20000); + }); + + test('200k value return 200000', () { + expect(Points.twoHundredThousand.value, 200000); + }); + + test('1m value return 1000000', () { + expect(Points.oneMillion.value, 1000000); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/score_text_effects_test.dart b/packages/pinball_components/test/src/components/score_text_effects_test.dart deleted file mode 100644 index 7f828f1d..00000000 --- a/packages/pinball_components/test/src/components/score_text_effects_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame/components.dart'; -import 'package:flame/effects.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() { - group('ScoreText', () { - final flameTester = FlameTester(TestGame.new); - - flameTester.testGameWidget( - 'renders correctly', - setUp: (game, tester) async { - game.camera.followVector2(Vector2.zero()); - await game.ensureAdd( - ScoreText( - text: '123', - position: Vector2.zero(), - color: Colors.white, - ), - ); - }, - verify: (game, tester) async { - final texts = game.descendants().whereType().length; - expect(texts, equals(1)); - }, - ); - - flameTester.testGameWidget( - 'has a movement effect', - setUp: (game, tester) async { - game.camera.followVector2(Vector2.zero()); - await game.ensureAdd( - ScoreText( - text: '123', - position: Vector2.zero(), - color: Colors.white, - ), - ); - - game.update(0.5); - await tester.pump(); - }, - verify: (game, tester) async { - final text = game.descendants().whereType().first; - expect(text.firstChild(), isNotNull); - }, - ); - - flameTester.testGameWidget( - 'is removed once finished', - setUp: (game, tester) async { - game.camera.followVector2(Vector2.zero()); - await game.ensureAdd( - ScoreText( - text: '123', - position: Vector2.zero(), - color: Colors.white, - ), - ); - - game.update(1); - game.update(0); // Ensure all component removals - }, - verify: (game, tester) async { - expect(game.children.length, equals(0)); - }, - ); - }); -} diff --git a/pubspec.lock b/pubspec.lock index db5233c3..ffbd3899 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -401,13 +401,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" - mockingjay: - dependency: "direct dev" - description: - name: mockingjay - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" mocktail: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index fa08f453..b98c84a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,6 @@ dev_dependencies: flame_test: ^1.3.0 flutter_test: sdk: flutter - mockingjay: ^0.3.0 mocktail: ^0.3.0 very_good_analysis: ^2.4.0 diff --git a/storage.rules b/storage.rules new file mode 100644 index 00000000..03ab51c6 --- /dev/null +++ b/storage.rules @@ -0,0 +1,9 @@ +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + match /{folder}/{imageId} { + allow read: if imageId.matches(".*\\.png") || imageId.matches(".*\\.jpg"); + allow write: if false; + } + } +} \ No newline at end of file diff --git a/test/game/components/google_word/behaviors/google_word_bonus_behavior_test.dart b/test/game/components/google_word/behaviors/google_word_bonus_behavior_test.dart index 97efc207..305a0c1f 100644 --- a/test/game/components/google_word/behaviors/google_word_bonus_behavior_test.dart +++ b/test/game/components/google_word/behaviors/google_word_bonus_behavior_test.dart @@ -3,7 +3,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockingjay/mockingjay.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/components/google_word/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; diff --git a/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart b/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart new file mode 100644 index 00000000..00049a83 --- /dev/null +++ b/test/game/components/multiballs/behaviors/multiballs_behavior_test.dart @@ -0,0 +1,136 @@ +// ignore_for_file: cascade_invocations, prefer_const_constructors + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/components/multiballs/behaviors/behaviors.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.multiball.lit.keyName, + Assets.images.multiball.dimmed.keyName, + ]; + + group('MultiballsBehavior', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + assets: assets, + ); + + group('listenWhen', () { + test( + 'is true when the bonusHistory has changed ' + 'with a new GameBonus.dashNest', () { + final previous = GameState.initial(); + final state = previous.copyWith( + bonusHistory: [GameBonus.dashNest], + ); + + expect( + MultiballsBehavior().listenWhen(previous, state), + isTrue, + ); + }); + + test( + 'is false when the bonusHistory has changed ' + 'with a bonus different than GameBonus.dashNest', () { + final previous = + GameState.initial().copyWith(bonusHistory: [GameBonus.dashNest]); + final state = previous.copyWith( + bonusHistory: [...previous.bonusHistory, GameBonus.androidSpaceship], + ); + + expect( + MultiballsBehavior().listenWhen(previous, state), + isFalse, + ); + }); + + test('is false when the bonusHistory state is the same', () { + final previous = GameState.initial(); + final state = GameState( + score: 10, + multiplier: 1, + rounds: 0, + bonusHistory: const [], + ); + + expect( + MultiballsBehavior().listenWhen(previous, state), + isFalse, + ); + }); + }); + + group('onNewState', () { + flameBlocTester.testGameWidget( + "calls 'onAnimate' once for every multiball", + setUp: (game, tester) async { + final behavior = MultiballsBehavior(); + final parent = Multiballs.test(); + final multiballCubit = MockMultiballCubit(); + final otherMultiballCubit = MockMultiballCubit(); + final multiballs = [ + Multiball.test( + bloc: multiballCubit, + ), + Multiball.test( + bloc: otherMultiballCubit, + ), + ]; + + whenListen( + multiballCubit, + const Stream.empty(), + initialState: MultiballState.initial(), + ); + when(multiballCubit.onAnimate).thenAnswer((_) async {}); + + whenListen( + otherMultiballCubit, + const Stream.empty(), + initialState: MultiballState.initial(), + ); + when(otherMultiballCubit.onAnimate).thenAnswer((_) async {}); + + await parent.addAll(multiballs); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + + await tester.pump(); + + behavior.onNewState( + GameState.initial().copyWith(bonusHistory: [GameBonus.dashNest]), + ); + + for (final multiball in multiballs) { + verify( + multiball.bloc.onAnimate, + ).called(1); + } + }, + ); + }); + }); +} diff --git a/test/game/components/multiballs/multiballs_test.dart b/test/game/components/multiballs/multiballs_test.dart new file mode 100644 index 00000000..c1a328b1 --- /dev/null +++ b/test/game/components/multiballs/multiballs_test.dart @@ -0,0 +1,54 @@ +// 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.multiball.lit.keyName, + Assets.images.multiball.dimmed.keyName, + ]; + late GameBloc gameBloc; + + setUp(() { + gameBloc = GameBloc(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + assets: assets, + ); + + group('Multiballs', () { + flameBlocTester.testGameWidget( + 'loads correctly', + setUp: (game, tester) async { + final multiballs = Multiballs(); + await game.ensureAdd(multiballs); + + expect(game.contains(multiballs), isTrue); + }, + ); + + group('loads', () { + flameBlocTester.testGameWidget( + 'four Multiball', + setUp: (game, tester) async { + final multiballs = Multiballs(); + await game.ensureAdd(multiballs); + + expect( + multiballs.descendants().whereType().length, + equals(4), + ); + }, + ); + }); + }); +} diff --git a/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart b/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart index a4f3502c..c4f2bd33 100644 --- a/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart +++ b/test/game/components/multipliers/behaviors/multipliers_behavior_test.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockingjay/mockingjay.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/components/multipliers/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; diff --git a/test/game/components/scoring_behavior_test.dart b/test/game/components/scoring_behavior_test.dart index 731bb481..2afa862d 100644 --- a/test/game/components/scoring_behavior_test.dart +++ b/test/game/components/scoring_behavior_test.dart @@ -18,6 +18,14 @@ class _TestBodyComponent extends BodyComponent { } 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; @@ -51,12 +59,13 @@ void main() { whenListen(bloc, Stream.value(state), initialState: state); return bloc; }, + assets: assets, ); flameBlocTester.testGameWidget( 'emits Scored event with points', setUp: (game, tester) async { - const points = 20; + const points = Points.oneMillion; final scoringBehavior = ScoringBehavior(points: points); await parent.add(scoringBehavior); final canvas = ZCanvasComponent(children: [parent]); @@ -66,7 +75,7 @@ void main() { verify( () => bloc.add( - const Scored(points: points), + Scored(points: points.value), ), ).called(1); }, @@ -75,8 +84,7 @@ void main() { flameBlocTester.testGameWidget( 'plays score sound', setUp: (game, tester) async { - const points = 20; - final scoringBehavior = ScoringBehavior(points: points); + final scoringBehavior = ScoringBehavior(points: Points.oneMillion); await parent.add(scoringBehavior); final canvas = ZCanvasComponent(children: [parent]); await game.ensureAdd(canvas); @@ -88,9 +96,9 @@ void main() { ); flameBlocTester.testGameWidget( - "adds a ScoreText component at Ball's position with points", + "adds a ScoreComponent at Ball's position with points", setUp: (game, tester) async { - const points = 20; + const points = Points.oneMillion; final scoringBehavior = ScoringBehavior(points: points); await parent.add(scoringBehavior); final canvas = ZCanvasComponent(children: [parent]); @@ -99,11 +107,11 @@ void main() { scoringBehavior.beginContact(ball, MockContact()); await game.ready(); - final scoreText = game.descendants().whereType(); + final scoreText = game.descendants().whereType(); expect(scoreText.length, equals(1)); expect( - scoreText.first.text, - equals(points.toString()), + scoreText.first.points, + equals(points), ); expect( scoreText.first.position, diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index c2357046..732c2d82 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -64,6 +64,8 @@ void main() { Assets.images.launchRamp.ramp.keyName, Assets.images.launchRamp.foregroundRailing.keyName, Assets.images.launchRamp.backgroundRailing.keyName, + Assets.images.multiball.lit.keyName, + Assets.images.multiball.dimmed.keyName, Assets.images.multiplier.x2.lit.keyName, Assets.images.multiplier.x2.dimmed.keyName, Assets.images.multiplier.x3.lit.keyName, @@ -178,6 +180,18 @@ void main() { ); }); + flameBlocTester.test( + 'has only one Multiballs', + (game) async { + await game.ready(); + + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + flameBlocTester.test( 'one GoogleWord', (game) async { @@ -400,54 +414,9 @@ void main() { game.onTapDown(tapDownEvent); - expect(plunger.body.linearVelocity.y, equals(7)); - }); - - flameTester.test('tap up releases plunger', (game) async { - final eventPosition = MockEventPosition(); - when(() => eventPosition.game).thenReturn(Vector2(40, 60)); - - final raw = MockTapDownDetails(); - when(() => raw.kind).thenReturn(PointerDeviceKind.touch); - - final tapDownEvent = MockTapDownInfo(); - when(() => tapDownEvent.eventPosition).thenReturn(eventPosition); - when(() => tapDownEvent.raw).thenReturn(raw); - - final plunger = game.descendants().whereType().first; - game.onTapDown(tapDownEvent); - - expect(plunger.body.linearVelocity.y, equals(7)); - - final tapUpEvent = MockTapUpInfo(); - when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); - - game.onTapUp(tapUpEvent); - - expect(plunger.body.linearVelocity.y, equals(0)); - }); - - flameTester.test('tap cancel releases plunger', (game) async { - await game.ready(); - - final eventPosition = MockEventPosition(); - when(() => eventPosition.game).thenReturn(Vector2(40, 60)); - - final raw = MockTapDownDetails(); - when(() => raw.kind).thenReturn(PointerDeviceKind.touch); - - final tapDownEvent = MockTapDownInfo(); - when(() => tapDownEvent.eventPosition).thenReturn(eventPosition); - when(() => tapDownEvent.raw).thenReturn(raw); - - final plunger = game.descendants().whereType().first; - game.onTapDown(tapDownEvent); - - expect(plunger.body.linearVelocity.y, equals(7)); - - game.onTapCancel(); + game.update(1); - expect(plunger.body.linearVelocity.y, equals(0)); + expect(plunger.body.linearVelocity.y, isPositive); }); }); }); diff --git a/test/game/view/widgets/bonus_animation_test.dart b/test/game/view/widgets/bonus_animation_test.dart index 11e249c7..b726ac83 100644 --- a/test/game/view/widgets/bonus_animation_test.dart +++ b/test/game/view/widgets/bonus_animation_test.dart @@ -24,6 +24,7 @@ class MockCallback extends Mock { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + const animationDuration = 6; setUp(() async { // TODO(arturplaczek): need to find for a better solution for loading image @@ -102,7 +103,7 @@ void main() { await tester.pump(); - await Future.delayed(const Duration(seconds: 4)); + await Future.delayed(const Duration(seconds: animationDuration)); await tester.pump(); @@ -133,7 +134,7 @@ void main() { .state(find.byType(BonusAnimation)) .didUpdateWidget(secondAnimation); - await Future.delayed(const Duration(seconds: 4)); + await Future.delayed(const Duration(seconds: animationDuration)); await tester.pump(); diff --git a/test/game/view/widgets/game_hud_test.dart b/test/game/view/widgets/game_hud_test.dart index 79cc4f33..505ce5e0 100644 --- a/test/game/view/widgets/game_hud_test.dart +++ b/test/game/view/widgets/game_hud_test.dart @@ -147,7 +147,7 @@ void main() { await tester.pump(); // TODO(arturplaczek): remove magic number once this is merged: // https://github.com/flame-engine/flame/pull/1564 - await Future.delayed(const Duration(seconds: 4)); + await Future.delayed(const Duration(seconds: 6)); await expectLater(find.byType(ScoreView), findsOneWidget); }); diff --git a/test/game/view/widgets/play_button_overlay_test.dart b/test/game/view/widgets/play_button_overlay_test.dart index 2229f4b5..ee9778bc 100644 --- a/test/game/view/widgets/play_button_overlay_test.dart +++ b/test/game/view/widgets/play_button_overlay_test.dart @@ -1,8 +1,10 @@ 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 '../../../helpers/helpers.dart'; @@ -12,7 +14,12 @@ void main() { late GameFlowController gameFlowController; late CharacterThemeCubit characterThemeCubit; - setUp(() { + 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(); @@ -49,7 +56,7 @@ void main() { characterThemeCubit: characterThemeCubit, ); await tester.tap(find.text('Play')); - await tester.pumpAndSettle(); + await tester.pump(); expect(find.byType(CharacterSelectionDialog), findsOneWidget); }); }); diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 58b4b126..fb27f72a 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -9,7 +9,6 @@ export 'fakes.dart'; export 'forge2d.dart'; export 'key_testers.dart'; export 'mocks.dart'; -export 'navigator.dart'; export 'pump_app.dart'; export 'test_games.dart'; export 'text_span.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 1d3ad3c7..7a5862e7 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -95,9 +95,7 @@ class MockAndroidBumper extends Mock implements AndroidBumper {} class MockSparkyBumper extends Mock implements SparkyBumper {} -class MockMultiplier extends Mock implements Multiplier {} - -class MockMultipliersGroup extends Mock implements Multipliers {} +class MockMultiballCubit extends Mock implements MultiballCubit {} class MockMultiplierCubit extends Mock implements MultiplierCubit {} diff --git a/test/helpers/navigator.dart b/test/helpers/navigator.dart deleted file mode 100644 index 5a8ea52e..00000000 --- a/test/helpers/navigator.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'helpers.dart'; - -Future expectNavigatesToRoute( - WidgetTester tester, - Route route, { - bool hasFlameGameInside = false, -}) async { - // ignore: avoid_dynamic_calls - await tester.pumpApp( - Scaffold( - body: Builder( - builder: (context) { - return ElevatedButton( - onPressed: () { - Navigator.of(context).push(route); - }, - child: const Text('Tap me'), - ); - }, - ), - ), - ); - - await tester.tap(find.text('Tap me')); - if (hasFlameGameInside) { - // We can't use pumpAndSettle here because the page renders a Flame game - // which is an infinity animation, so it will timeout - await tester.pump(); // Runs the button action - await tester.pump(); // Runs the navigation - } else { - await tester.pumpAndSettle(); - } - - expect(find.byType(Type), findsOneWidget); -} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index be67d4d0..672f9b5e 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -11,7 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; -import 'package:mockingjay/mockingjay.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/select_character/select_character.dart'; @@ -51,7 +51,6 @@ MockAssetsManagerCubit _buildDefaultAssetsManagerCubit() { extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { - MockNavigator? navigator, GameBloc? gameBloc, StartGameBloc? startGameBloc, AssetsManagerCubit? assetsManagerCubit, @@ -92,9 +91,7 @@ extension PumpApp on WidgetTester { GlobalMaterialLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: navigator != null - ? MockNavigatorProvider(navigator: navigator, child: widget) - : widget, + home: widget, ), ), ), 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 24c683a4..2e3d3fd4 100644 --- a/test/how_to_play/how_to_play_dialog_test.dart +++ b/test/how_to_play/how_to_play_dialog_test.dart @@ -73,5 +73,25 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(HowToPlayDialog), findsNothing); }); + + testWidgets('can be dismissed', (tester) async { + await tester.pumpApp( + Builder( + builder: (context) { + return TextButton( + onPressed: () => showHowToPlayDialog(context), + child: const Text('test'), + ); + }, + ), + ); + expect(find.byType(HowToPlayDialog), findsNothing); + await tester.tap(find.text('test')); + await tester.pumpAndSettle(); + + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + expect(find.byType(HowToPlayDialog), findsNothing); + }); }); } diff --git a/test/select_character/view/character_selection_page_test.dart b/test/select_character/view/character_selection_page_test.dart index b9c95f7f..c5cfb494 100644 --- a/test/select_character/view/character_selection_page_test.dart +++ b/test/select_character/view/character_selection_page_test.dart @@ -1,4 +1,5 @@ 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'; @@ -10,9 +11,15 @@ import 'package:pinball_ui/pinball_ui.dart'; import '../../helpers/helpers.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); late CharacterThemeCubit characterThemeCubit; - setUp(() { + 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); characterThemeCubit = MockCharacterThemeCubit(); whenListen( characterThemeCubit, @@ -38,7 +45,7 @@ void main() { characterThemeCubit: characterThemeCubit, ); await tester.tap(find.text('test')); - await tester.pumpAndSettle(); + await tester.pump(); expect(find.byType(CharacterSelectionDialog), findsOneWidget); }); }); @@ -50,7 +57,7 @@ void main() { characterThemeCubit: characterThemeCubit, ); await tester.tap(find.byKey(const Key('sparky_character_selection'))); - await tester.pumpAndSettle(); + await tester.pump(); verify( () => characterThemeCubit.characterSelected(const SparkyTheme()), ).called(1); @@ -68,5 +75,47 @@ void main() { expect(find.byType(CharacterSelectionDialog), findsNothing); expect(find.byType(HowToPlayDialog), findsOneWidget); }); + + testWidgets('updating the selected character updates the preview', + (tester) async { + await tester.pumpApp(_TestCharacterPreview()); + expect(find.text('Dash'), findsOneWidget); + await tester.tap(find.text('test')); + await tester.pump(); + expect(find.text('Android'), findsOneWidget); + }); }); } + +class _TestCharacterPreview extends StatefulWidget { + @override + State createState() => _TestCharacterPreviewState(); +} + +class _TestCharacterPreviewState extends State<_TestCharacterPreview> { + late CharacterTheme currentCharacter; + + @override + void initState() { + super.initState(); + currentCharacter = const DashTheme(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: SelectedCharacter(currentCharacter: currentCharacter)), + TextButton( + onPressed: () { + setState(() { + currentCharacter = const AndroidTheme(); + }); + }, + child: const Text('test'), + ) + ], + ); + } +} diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index 69c31fc5..b749bfef 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index d920815d..88cfd48d 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/favicon.png b/web/icons/favicon.png index 66a69cb1..8aaa46ac 100644 Binary files a/web/icons/favicon.png and b/web/icons/favicon.png differ diff --git a/web/index.html b/web/index.html index 471a2f3f..107b34ba 100644 --- a/web/index.html +++ b/web/index.html @@ -24,27 +24,22 @@ + - - - - - - + content="https://firebasestorage.googleapis.com/v0/b/pinball-dev.appspot.com/o/images%2Fpinball_share_image.png?alt=media"> + + - -