diff --git a/lib/game/components/android_acres/android_acres.dart b/lib/game/components/android_acres/android_acres.dart index 82b71741..649ef196 100644 --- a/lib/game/components/android_acres/android_acres.dart +++ b/lib/game/components/android_acres/android_acres.dart @@ -15,7 +15,16 @@ class AndroidAcres extends Component { AndroidAcres() : super( children: [ - SpaceshipRamp(), + SpaceshipRamp( + children: [ + RampShotBehavior( + points: Points.fiveThousand, + ), + RampBonusBehavior( + points: Points.oneMillion, + ), + ], + ), SpaceshipRail(), AndroidSpaceship(position: Vector2(-26.5, -28.5)), AndroidAnimatronic( diff --git a/lib/game/components/android_acres/behaviors/behaviors.dart b/lib/game/components/android_acres/behaviors/behaviors.dart index e4ac5981..91b1e132 100644 --- a/lib/game/components/android_acres/behaviors/behaviors.dart +++ b/lib/game/components/android_acres/behaviors/behaviors.dart @@ -1 +1,3 @@ export 'android_spaceship_bonus_behavior.dart'; +export 'ramp_bonus_behavior.dart'; +export 'ramp_shot_behavior.dart'; diff --git a/lib/game/components/android_acres/behaviors/ramp_bonus_behavior.dart b/lib/game/components/android_acres/behaviors/ramp_bonus_behavior.dart new file mode 100644 index 00000000..218ad8b4 --- /dev/null +++ b/lib/game/components/android_acres/behaviors/ramp_bonus_behavior.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template ramp_bonus_behavior} +/// Increases the score when a [Ball] is shot 10 times into the [SpaceshipRamp]. +/// {@endtemplate} +class RampBonusBehavior extends Component + with ParentIsA, HasGameRef { + /// {@macro ramp_bonus_behavior} + RampBonusBehavior({ + required Points points, + }) : _points = points, + super(); + + /// Creates a [RampBonusBehavior]. + /// + /// This can be used for testing [RampBonusBehavior] in isolation. + @visibleForTesting + RampBonusBehavior.test({ + required Points points, + required this.subscription, + }) : _points = points, + super(); + + final Points _points; + + /// Subscription to [SpaceshipRampState] at [SpaceshipRamp]. + @visibleForTesting + StreamSubscription? subscription; + + @override + void onMount() { + super.onMount(); + + subscription = subscription ?? + parent.bloc.stream.listen((state) { + final achievedOneMillionPoints = state.hits % 10 == 0; + + if (achievedOneMillionPoints) { + parent.add( + ScoringBehavior( + points: _points, + position: Vector2(0, -60), + duration: 2, + ), + ); + } + }); + } + + @override + void onRemove() { + subscription?.cancel(); + super.onRemove(); + } +} diff --git a/lib/game/components/android_acres/behaviors/ramp_shot_behavior.dart b/lib/game/components/android_acres/behaviors/ramp_shot_behavior.dart new file mode 100644 index 00000000..8a9c1a9c --- /dev/null +++ b/lib/game/components/android_acres/behaviors/ramp_shot_behavior.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template ramp_shot_behavior} +/// Increases the score when a [Ball] is shot into the [SpaceshipRamp]. +/// {@endtemplate} +class RampShotBehavior extends Component + with ParentIsA, HasGameRef { + /// {@macro ramp_shot_behavior} + RampShotBehavior({ + required Points points, + }) : _points = points, + super(); + + /// Creates a [RampShotBehavior]. + /// + /// This can be used for testing [RampShotBehavior] in isolation. + @visibleForTesting + RampShotBehavior.test({ + required Points points, + required this.subscription, + }) : _points = points, + super(); + + final Points _points; + + /// Subscription to [SpaceshipRampState] at [SpaceshipRamp]. + @visibleForTesting + StreamSubscription? subscription; + + @override + void onMount() { + super.onMount(); + + subscription = subscription ?? + parent.bloc.stream.listen((state) { + final achievedOneMillionPoints = state.hits % 10 == 0; + + if (!achievedOneMillionPoints) { + gameRef.read().add(const MultiplierIncreased()); + + parent.add( + ScoringBehavior( + points: _points, + position: Vector2(0, -45), + ), + ); + } + }); + } + + @override + void onRemove() { + subscription?.cancel(); + super.onRemove(); + } +} diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 2a3d5061..bc84fb2b 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -31,7 +31,7 @@ export 'shapes/shapes.dart'; export 'signpost/signpost.dart'; export 'slingshot.dart'; export 'spaceship_rail.dart'; -export 'spaceship_ramp.dart'; +export 'spaceship_ramp/spaceship_ramp.dart'; export 'sparky_animatronic.dart'; export 'sparky_bumper/sparky_bumper.dart'; export 'sparky_computer.dart'; diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/behavior.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/behavior.dart new file mode 100644 index 00000000..1f9b6284 --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/behavior.dart @@ -0,0 +1 @@ +export 'ramp_ball_ascending_contact_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior.dart new file mode 100644 index 00000000..2d0aad7c --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior.dart @@ -0,0 +1,24 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template ramp_ball_ascending_contact_behavior} +/// Detects an ascending [Ball] that enters into the [SpaceshipRamp]. +/// +/// The [Ball] can hit with sensor to recognize if a [Ball] goes into or out of +/// the [SpaceshipRamp]. +/// {@endtemplate} +class RampBallAscendingContactBehavior + extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + + if (other.body.linearVelocity.y < 0) { + parent.parent.bloc.onAscendingBallEntered(); + } + } +} diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit.dart new file mode 100644 index 00000000..d27a7a2c --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit.dart @@ -0,0 +1,16 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'spaceship_ramp_state.dart'; + +class SpaceshipRampCubit extends Cubit { + SpaceshipRampCubit() : super(const SpaceshipRampState.initial()); + + void onAscendingBallEntered() { + emit( + state.copyWith(hits: state.hits + 1), + ); + } +} diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_state.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_state.dart new file mode 100644 index 00000000..7fae894f --- /dev/null +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/cubit/spaceship_ramp_state.dart @@ -0,0 +1,24 @@ +// ignore_for_file: public_member_api_docs + +part of 'spaceship_ramp_cubit.dart'; + +class SpaceshipRampState extends Equatable { + const SpaceshipRampState({ + required this.hits, + }) : assert(hits >= 0, "Hits can't be negative"); + + const SpaceshipRampState.initial() : this(hits: 0); + + final int hits; + + SpaceshipRampState copyWith({ + int? hits, + }) { + return SpaceshipRampState( + hits: hits ?? this.hits, + ); + } + + @override + List get props => [hits]; +} diff --git a/packages/pinball_components/lib/src/components/spaceship_ramp.dart b/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart similarity index 77% rename from packages/pinball_components/lib/src/components/spaceship_ramp.dart rename to packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart index c1be0943..0b407517 100644 --- a/packages/pinball_components/lib/src/components/spaceship_ramp.dart +++ b/packages/pinball_components/lib/src/components/spaceship_ramp/spaceship_ramp.dart @@ -5,16 +5,35 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; import 'package:pinball_components/gen/assets.gen.dart'; import 'package:pinball_components/pinball_components.dart' hide Assets; +import 'package:pinball_components/src/components/spaceship_ramp/behavior/behavior.dart'; import 'package:pinball_flame/pinball_flame.dart'; +export 'cubit/spaceship_ramp_cubit.dart'; + /// {@template spaceship_ramp} /// Ramp leading into the [AndroidSpaceship]. /// {@endtemplate} class SpaceshipRamp extends Component { /// {@macro spaceship_ramp} - SpaceshipRamp() - : super( + SpaceshipRamp({ + Iterable? children, + }) : this._( + children: children, + bloc: SpaceshipRampCubit(), + ); + + SpaceshipRamp._({ + Iterable? children, + required this.bloc, + }) : super( children: [ + // TODO(ruimiguel): refactor RampScoringSensor and + // _SpaceshipRampOpening to be in only one sensor if possible. + RampScoringSensor( + children: [ + RampBallAscendingContactBehavior(), + ], + )..initialPosition = Vector2(1.7, -20.4), _SpaceshipRampOpening( outsidePriority: ZIndexes.ballOnBoard, rotation: math.pi, @@ -34,60 +53,30 @@ class SpaceshipRamp extends Component { _SpaceshipRampForegroundRailing(), _SpaceshipRampBase()..initialPosition = Vector2(1.7, -20), _SpaceshipRampBackgroundRailingSpriteComponent(), - _SpaceshipRampArrowSpriteComponent(), + SpaceshipRampArrowSpriteComponent( + current: bloc.state.hits, + ), + ...?children, ], ); - /// Forwards the sprite to the next [SpaceshipRampArrowSpriteState]. + /// Creates a [SpaceshipRamp] without any children. /// - /// If the current state is the last one it cycles back to the initial state. - void progress() => - firstChild<_SpaceshipRampArrowSpriteComponent>()?.progress(); -} - -/// Indicates the state of the arrow on the [SpaceshipRamp]. -@visibleForTesting -enum SpaceshipRampArrowSpriteState { - /// Arrow with no dashes lit up. - inactive, - - /// Arrow with 1 light lit up. - active1, + /// This can be used for testing [SpaceshipRamp]'s behaviors in isolation. + @visibleForTesting + SpaceshipRamp.test({ + required this.bloc, + }) : super(); - /// Arrow with 2 lights lit up. - active2, - - /// Arrow with 3 lights lit up. - active3, - - /// Arrow with 4 lights lit up. - active4, - - /// Arrow with all 5 lights lit up. - active5, -} - -extension on SpaceshipRampArrowSpriteState { - String get path { - switch (this) { - case SpaceshipRampArrowSpriteState.inactive: - return Assets.images.android.ramp.arrow.inactive.keyName; - case SpaceshipRampArrowSpriteState.active1: - return Assets.images.android.ramp.arrow.active1.keyName; - case SpaceshipRampArrowSpriteState.active2: - return Assets.images.android.ramp.arrow.active2.keyName; - case SpaceshipRampArrowSpriteState.active3: - return Assets.images.android.ramp.arrow.active3.keyName; - case SpaceshipRampArrowSpriteState.active4: - return Assets.images.android.ramp.arrow.active4.keyName; - case SpaceshipRampArrowSpriteState.active5: - return Assets.images.android.ramp.arrow.active5.keyName; - } - } + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final SpaceshipRampCubit bloc; - SpaceshipRampArrowSpriteState get next { - return SpaceshipRampArrowSpriteState - .values[(index + 1) % SpaceshipRampArrowSpriteState.values.length]; + @override + void onRemove() { + bloc.close(); + super.onRemove(); } } @@ -194,37 +183,81 @@ class _SpaceshipRampBackgroundRampSpriteComponent extends SpriteComponent /// /// Lights progressively whenever a [Ball] gets into [SpaceshipRamp]. /// {@endtemplate} -class _SpaceshipRampArrowSpriteComponent - extends SpriteGroupComponent - with HasGameRef, ZIndex { +@visibleForTesting +class SpaceshipRampArrowSpriteComponent extends SpriteGroupComponent + with HasGameRef, ParentIsA, ZIndex { /// {@macro spaceship_ramp_arrow_sprite_component} - _SpaceshipRampArrowSpriteComponent() - : super( + SpaceshipRampArrowSpriteComponent({ + required int current, + }) : super( anchor: Anchor.center, position: Vector2(-3.9, -56.5), + current: current, ) { zIndex = ZIndexes.spaceshipRampArrow; } - /// Changes arrow image to the next [Sprite]. - void progress() => current = current?.next; - @override Future onLoad() async { await super.onLoad(); - final sprites = {}; + parent.bloc.stream.listen((state) { + current = state.hits % SpaceshipRampArrowSpriteState.values.length; + }); + + final sprites = {}; this.sprites = sprites; for (final spriteState in SpaceshipRampArrowSpriteState.values) { - sprites[spriteState] = Sprite( + sprites[spriteState.index] = Sprite( gameRef.images.fromCache(spriteState.path), ); } - current = SpaceshipRampArrowSpriteState.inactive; + current = 0; size = sprites[current]!.originalSize / 10; } } +/// Indicates the state of the arrow on the [SpaceshipRamp]. +@visibleForTesting +enum SpaceshipRampArrowSpriteState { + /// Arrow with no dashes lit up. + inactive, + + /// Arrow with 1 light lit up. + active1, + + /// Arrow with 2 lights lit up. + active2, + + /// Arrow with 3 lights lit up. + active3, + + /// Arrow with 4 lights lit up. + active4, + + /// Arrow with all 5 lights lit up. + active5, +} + +extension on SpaceshipRampArrowSpriteState { + String get path { + switch (this) { + case SpaceshipRampArrowSpriteState.inactive: + return Assets.images.android.ramp.arrow.inactive.keyName; + case SpaceshipRampArrowSpriteState.active1: + return Assets.images.android.ramp.arrow.active1.keyName; + case SpaceshipRampArrowSpriteState.active2: + return Assets.images.android.ramp.arrow.active2.keyName; + case SpaceshipRampArrowSpriteState.active3: + return Assets.images.android.ramp.arrow.active3.keyName; + case SpaceshipRampArrowSpriteState.active4: + return Assets.images.android.ramp.arrow.active4.keyName; + case SpaceshipRampArrowSpriteState.active5: + return Assets.images.android.ramp.arrow.active5.keyName; + } + } +} + class _SpaceshipRampBoardOpeningSpriteComponent extends SpriteComponent with HasGameRef, ZIndex { _SpaceshipRampBoardOpeningSpriteComponent() : super(anchor: Anchor.center) { @@ -373,3 +406,47 @@ class _SpaceshipRampOpening extends LayerSensor { ); } } + +/// {@template ramp_scoring_sensor} +/// Small sensor body used to detect when a ball has entered the +/// [SpaceshipRamp]. +/// {@endtemplate} +class RampScoringSensor extends BodyComponent + with ParentIsA, InitialPosition, Layered { + /// {@macro ramp_scoring_sensor} + RampScoringSensor({ + Iterable? children, + }) : super( + children: children, + renderBody: false, + ) { + layer = Layer.spaceshipEntranceRamp; + } + + /// Creates a [RampScoringSensor] without any children. + /// + @visibleForTesting + RampScoringSensor.test(); + + @override + Body createBody() { + final shape = PolygonShape() + ..setAsBox( + 2.6, + .5, + initialPosition, + -5 * math.pi / 180, + ); + + final fixtureDef = FixtureDef( + shape, + isSensor: true, + ); + final bodyDef = BodyDef( + position: initialPosition, + userData: this, + ); + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart index cabe4d54..3446670a 100644 --- a/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/android_acres/spaceship_ramp_game.dart @@ -54,7 +54,7 @@ class SpaceshipRampGame extends BallGame with KeyboardEvents { ) { if (event is RawKeyDownEvent && event.logicalKey == LogicalKeyboardKey.space) { - _spaceshipRamp.progress(); + _spaceshipRamp.bloc.onAscendingBallEntered(); return KeyEventResult.handled; } diff --git a/packages/pinball_components/test/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior_test.dart new file mode 100644 index 00000000..ea37550a --- /dev/null +++ b/packages/pinball_components/test/src/components/spaceship_ramp/behavior/ramp_ball_ascending_contact_behavior_test.dart @@ -0,0 +1,117 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/spaceship_ramp/behavior/behavior.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} + +class _MockBall extends Mock implements Ball {} + +class _MockBody extends Mock implements Body {} + +class _MockContact extends Mock implements Contact {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.android.ramp.boardOpening.keyName, + Assets.images.android.ramp.railingForeground.keyName, + Assets.images.android.ramp.railingBackground.keyName, + Assets.images.android.ramp.main.keyName, + Assets.images.android.ramp.arrow.inactive.keyName, + Assets.images.android.ramp.arrow.active1.keyName, + Assets.images.android.ramp.arrow.active2.keyName, + Assets.images.android.ramp.arrow.active3.keyName, + Assets.images.android.ramp.arrow.active4.keyName, + Assets.images.android.ramp.arrow.active5.keyName, + ]; + + final flameTester = FlameTester(() => TestGame(assets)); + + group( + 'RampBallAscendingContactBehavior', + () { + test('can be instantiated', () { + expect( + RampBallAscendingContactBehavior(), + isA(), + ); + }); + + group('beginContact', () { + late Ball ball; + late Body body; + + setUp(() { + ball = _MockBall(); + body = _MockBody(); + + when(() => ball.body).thenReturn(body); + }); + + flameTester.test( + "calls 'onAscendingBallEntered' when a ball enters into the ramp", + (game) async { + final behavior = RampBallAscendingContactBehavior(); + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SpaceshipRampState.initial(), + ); + + final rampSensor = RampScoringSensor.test(); + final spaceshipRamp = SpaceshipRamp.test( + bloc: bloc, + ); + + when(() => body.linearVelocity).thenReturn(Vector2(0, -1)); + + await spaceshipRamp.add(rampSensor); + await game.ensureAddAll([spaceshipRamp, ball]); + await rampSensor.add(behavior); + + behavior.beginContact(ball, _MockContact()); + + verify(bloc.onAscendingBallEntered).called(1); + }, + ); + + flameTester.test( + "doesn't call 'onAscendingBallEntered' when a ball goes out the ramp", + (game) async { + final behavior = RampBallAscendingContactBehavior(); + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SpaceshipRampState.initial(), + ); + + final rampSensor = RampScoringSensor.test(); + final spaceshipRamp = SpaceshipRamp.test( + bloc: bloc, + ); + + when(() => body.linearVelocity).thenReturn(Vector2(0, 1)); + + await spaceshipRamp.add(rampSensor); + await game.ensureAddAll([spaceshipRamp, ball]); + await rampSensor.add(behavior); + + behavior.beginContact(ball, _MockContact()); + + verifyNever(bloc.onAscendingBallEntered); + }, + ); + }); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit_test.dart new file mode 100644 index 00000000..b7e899fe --- /dev/null +++ b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_cubit_test.dart @@ -0,0 +1,25 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group('SpaceshipRampCubit', () { + group('onAscendingBallEntered', () { + blocTest( + 'emits hits incremented and arrow goes to the next value', + build: SpaceshipRampCubit.new, + act: (bloc) => bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(), + expect: () => [ + SpaceshipRampState(hits: 1), + SpaceshipRampState(hits: 2), + SpaceshipRampState(hits: 3), + ], + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_state_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_state_test.dart new file mode 100644 index 00000000..536f4e8e --- /dev/null +++ b/packages/pinball_components/test/src/components/spaceship_ramp/cubit/spaceship_ramp_state_test.dart @@ -0,0 +1,78 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/src/components/components.dart'; + +void main() { + group('SpaceshipRampState', () { + test('supports value equality', () { + expect( + SpaceshipRampState(hits: 0), + equals( + SpaceshipRampState(hits: 0), + ), + ); + }); + + group('constructor', () { + test('can be instantiated', () { + expect( + SpaceshipRampState(hits: 0), + isNotNull, + ); + }); + }); + + test( + 'throws AssertionError ' + 'when hits is negative', + () { + expect( + () => SpaceshipRampState(hits: -1), + throwsAssertionError, + ); + }, + ); + + group('copyWith', () { + test( + 'throws AssertionError ' + 'when hits is decreased', + () { + const rampState = SpaceshipRampState(hits: 0); + expect( + () => rampState.copyWith(hits: rampState.hits - 1), + throwsAssertionError, + ); + }, + ); + + test( + 'copies correctly ' + 'when no argument specified', + () { + const rampState = SpaceshipRampState(hits: 0); + expect( + rampState.copyWith(), + equals(rampState), + ); + }, + ); + + test( + 'copies correctly ' + 'when all arguments specified', + () { + const rampState = SpaceshipRampState(hits: 0); + final otherRampState = SpaceshipRampState(hits: rampState.hits + 1); + expect(rampState, isNot(equals(otherRampState))); + + expect( + rampState.copyWith(hits: rampState.hits + 1), + equals(otherRampState), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/spaceship_ramp_test.dart b/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart similarity index 53% rename from packages/pinball_components/test/src/components/spaceship_ramp_test.dart rename to packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart index 0f2ce13a..b74cfb88 100644 --- a/packages/pinball_components/test/src/components/spaceship_ramp_test.dart +++ b/packages/pinball_components/test/src/components/spaceship_ramp/spaceship_ramp_test.dart @@ -1,12 +1,16 @@ // ignore_for_file: cascade_invocations +import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; -import '../../helpers/helpers.dart'; +import '../../../helpers/helpers.dart'; + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -25,28 +29,35 @@ void main() { final flameTester = FlameTester(() => TestGame(assets)); group('SpaceshipRamp', () { - flameTester.test('loads correctly', (game) async { - final component = SpaceshipRamp(); - await game.ensureAdd(component); - expect(game.contains(component), isTrue); - }); + flameTester.test( + 'loads correctly', + (game) async { + final spaceshipRamp = SpaceshipRamp(); + await game.ensureAdd(spaceshipRamp); + expect(game.children, contains(spaceshipRamp)); + }, + ); group('renders correctly', () { - const goldenFilePath = 'golden/spaceship_ramp/'; + const goldenFilePath = '../golden/spaceship_ramp/'; final centerForSpaceshipRamp = Vector2(-13, -55); flameTester.testGameWidget( 'inactive sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.inactive, ); @@ -64,15 +75,21 @@ void main() { 'active1 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component.progress(); + ramp.bloc.onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active1, ); @@ -90,17 +107,23 @@ void main() { 'active2 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active2, ); @@ -118,18 +141,24 @@ void main() { 'active3 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active3, ); @@ -147,19 +176,25 @@ void main() { 'active4 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress() - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active4, ); @@ -177,20 +212,26 @@ void main() { 'active5 sprite', setUp: (game, tester) async { await game.images.loadAll(assets); - final component = SpaceshipRamp(); - final canvas = ZCanvasComponent(children: [component]); + final ramp = SpaceshipRamp(); + final canvas = ZCanvasComponent(children: [ramp]); await game.ensureAdd(canvas); - component - ..progress() - ..progress() - ..progress() - ..progress() - ..progress(); + ramp.bloc + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered() + ..onAscendingBallEntered(); + + await game.ready(); await tester.pump(); + final index = ramp.children + .whereType() + .first + .current; expect( - component.children.whereType().first.current, + SpaceshipRampArrowSpriteState.values[index!], SpaceshipRampArrowSpriteState.active5, ); @@ -204,5 +245,34 @@ void main() { }, ); }); + + flameTester.test('closes bloc when removed', (game) async { + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SpaceshipRampState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + + final ramp = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ramp); + game.remove(ramp); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final ramp = SpaceshipRamp(children: [component]); + await game.ensureAdd(ramp); + expect(ramp.children, contains(component)); + }); + }); }); } diff --git a/test/game/components/android_acres/behaviors/ramp_bonus_behavior_test.dart b/test/game/components/android_acres/behaviors/ramp_bonus_behavior_test.dart new file mode 100644 index 00000000..acd17717 --- /dev/null +++ b/test/game/components/android_acres/behaviors/ramp_bonus_behavior_test.dart @@ -0,0 +1,152 @@ +// ignore_for_file: cascade_invocations, prefer_const_constructors + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/game/components/android_acres/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockGameBloc extends Mock implements GameBloc {} + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} + +class _MockStreamSubscription extends Mock + implements StreamSubscription {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.android.ramp.boardOpening.keyName, + Assets.images.android.ramp.railingForeground.keyName, + Assets.images.android.ramp.railingBackground.keyName, + Assets.images.android.ramp.main.keyName, + Assets.images.android.ramp.arrow.inactive.keyName, + Assets.images.android.ramp.arrow.active1.keyName, + Assets.images.android.ramp.arrow.active2.keyName, + Assets.images.android.ramp.arrow.active3.keyName, + Assets.images.android.ramp.arrow.active4.keyName, + Assets.images.android.ramp.arrow.active5.keyName, + Assets.images.android.rail.main.keyName, + Assets.images.android.rail.exit.keyName, + Assets.images.score.oneMillion.keyName, + ]; + + group('RampBonusBehavior', () { + const shotPoints = Points.oneMillion; + + late GameBloc gameBloc; + + setUp(() { + gameBloc = _MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + assets: assets, + ); + + flameBlocTester.testGameWidget( + 'when hits are multiples of 10 times adds a ScoringBehavior', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState(hits: 9), + ); + final behavior = RampBonusBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 10)); + + final scores = game.descendants().whereType(); + await game.ready(); + + expect(scores.length, 1); + }, + ); + + flameBlocTester.testGameWidget( + "when hits are not multiple of 10 times doesn't add any ScoringBehavior", + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState.initial(), + ); + final behavior = RampBonusBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 1)); + + final scores = game.descendants().whereType(); + await game.ready(); + + expect(scores.length, 0); + }, + ); + + flameBlocTester.testGameWidget( + 'closes subscription when removed', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SpaceshipRampState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + + final subscription = _MockStreamSubscription(); + when(subscription.cancel).thenAnswer((_) async {}); + + final behavior = RampBonusBehavior.test( + points: shotPoints, + subscription: subscription, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + parent.remove(behavior); + await game.ready(); + + verify(subscription.cancel).called(1); + }, + ); + }); +} diff --git a/test/game/components/android_acres/behaviors/ramp_shot_behavior_test.dart b/test/game/components/android_acres/behaviors/ramp_shot_behavior_test.dart new file mode 100644 index 00000000..23f02220 --- /dev/null +++ b/test/game/components/android_acres/behaviors/ramp_shot_behavior_test.dart @@ -0,0 +1,156 @@ +// ignore_for_file: cascade_invocations, prefer_const_constructors + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; +import 'package:pinball/game/components/android_acres/behaviors/behaviors.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockGameBloc extends Mock implements GameBloc {} + +class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} + +class _MockStreamSubscription extends Mock + implements StreamSubscription {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.android.ramp.boardOpening.keyName, + Assets.images.android.ramp.railingForeground.keyName, + Assets.images.android.ramp.railingBackground.keyName, + Assets.images.android.ramp.main.keyName, + Assets.images.android.ramp.arrow.inactive.keyName, + Assets.images.android.ramp.arrow.active1.keyName, + Assets.images.android.ramp.arrow.active2.keyName, + Assets.images.android.ramp.arrow.active3.keyName, + Assets.images.android.ramp.arrow.active4.keyName, + Assets.images.android.ramp.arrow.active5.keyName, + Assets.images.android.rail.main.keyName, + Assets.images.android.rail.exit.keyName, + Assets.images.score.fiveThousand.keyName, + ]; + + group('RampShotBehavior', () { + const shotPoints = Points.fiveThousand; + + late GameBloc gameBloc; + + setUp(() { + gameBloc = _MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballTestGame.new, + blocBuilder: () => gameBloc, + assets: assets, + ); + + flameBlocTester.testGameWidget( + 'when hits are not multiple of 10 times ' + 'increases multiplier and adds a ScoringBehavior', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState.initial(), + ); + final behavior = RampShotBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 1)); + + final scores = game.descendants().whereType(); + await game.ready(); + + verify(() => gameBloc.add(MultiplierIncreased())).called(1); + expect(scores.length, 1); + }, + ); + + flameBlocTester.testGameWidget( + 'when hits multiple of 10 times ' + "doesn't increase multiplier, neither ScoringBehavior", + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: SpaceshipRampState(hits: 9), + ); + final behavior = RampShotBehavior( + points: shotPoints, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + streamController.add(SpaceshipRampState(hits: 10)); + + final scores = game.children.whereType(); + await game.ready(); + + verifyNever(() => gameBloc.add(MultiplierIncreased())); + expect(scores.length, 0); + }, + ); + + flameBlocTester.testGameWidget( + 'closes subscription when removed', + setUp: (game, tester) async { + final bloc = _MockSpaceshipRampCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: SpaceshipRampState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + + final subscription = _MockStreamSubscription(); + when(subscription.cancel).thenAnswer((_) async {}); + + final behavior = RampShotBehavior.test( + points: shotPoints, + subscription: subscription, + ); + final parent = SpaceshipRamp.test( + bloc: bloc, + ); + + await game.ensureAdd(ZCanvasComponent(children: [parent])); + await parent.ensureAdd(behavior); + + parent.remove(behavior); + await game.ready(); + + verify(subscription.cancel).called(1); + }, + ); + }); +} diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index ca31f280..f942c47c 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -513,8 +513,11 @@ void main() { game.onTapUp(0, tapUpEvent); await game.ready(); + final currentBalls = + game.descendants().whereType().toList(); + expect( - game.descendants().whereType().length, + currentBalls.length, equals(previousBalls.length + 1), ); },