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/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/game_assets.dart b/lib/game/game_assets.dart index d8532c50..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), diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 44266229..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(), 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/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index d526909e..3e2faad1 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -28,6 +28,7 @@ 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(); @@ -180,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(); diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 394f32ed..5b661691 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -21,6 +21,7 @@ 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'; 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/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 1d2232e0..61e62386 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -81,6 +81,7 @@ 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/ diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index 25473f02..9fdee65a 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -27,6 +27,7 @@ void main() { addScoreStories(dashbook); addBackboardStories(dashbook); addDinoWallStories(dashbook); + addMultiballStories(dashbook); addMultipliersStories(dashbook); runApp(dashbook); 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/stories.dart b/packages/pinball_components/sandbox/lib/stories/stories.dart index 89bf5d96..8cdd38b1 100644 --- a/packages/pinball_components/sandbox/lib/stories/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/stories.dart @@ -10,6 +10,7 @@ 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/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/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/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/pinball_game_test.dart b/test/game/pinball_game_test.dart index a88aa0df..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 { 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 {}