diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index d066ce0d..ac324417 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -131,6 +131,10 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.flapper.backSupport.keyName), images.load(components.Assets.images.flapper.frontSupport.keyName), images.load(components.Assets.images.flapper.flap.keyName), + images.load(components.Assets.images.skillShot.decal.keyName), + images.load(components.Assets.images.skillShot.pin.keyName), + images.load(components.Assets.images.skillShot.lit.keyName), + images.load(components.Assets.images.skillShot.dimmed.keyName), images.load(dashTheme.leaderboardIcon.keyName), images.load(sparkyTheme.leaderboardIcon.keyName), images.load(androidTheme.leaderboardIcon.keyName), diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index aa963a53..bdf23759 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -7,6 +7,7 @@ import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball_audio/pinball_audio.dart'; @@ -57,6 +58,11 @@ class PinballGame extends PinballForge2DGame GoogleWord(position: Vector2(-4.25, 1.8)), Multipliers(), Multiballs(), + SkillShot( + children: [ + ScoringContactBehavior(points: Points.oneMillion), + ], + ), ]; final characterAreas = [ AndroidAcres(), diff --git a/packages/pinball_components/assets/images/skill_shot/decal.png b/packages/pinball_components/assets/images/skill_shot/decal.png new file mode 100644 index 00000000..120d70aa Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/decal.png differ diff --git a/packages/pinball_components/assets/images/skill_shot/dimmed.png b/packages/pinball_components/assets/images/skill_shot/dimmed.png new file mode 100644 index 00000000..7cc32bd4 Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/dimmed.png differ diff --git a/packages/pinball_components/assets/images/skill_shot/lit.png b/packages/pinball_components/assets/images/skill_shot/lit.png new file mode 100644 index 00000000..d1bce99b Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/lit.png differ diff --git a/packages/pinball_components/assets/images/skill_shot/pin.png b/packages/pinball_components/assets/images/skill_shot/pin.png new file mode 100644 index 00000000..5b64e1ab Binary files /dev/null and b/packages/pinball_components/assets/images/skill_shot/pin.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index 93273683..cac04cc0 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -35,6 +35,7 @@ class $AssetsImagesGen { $AssetsImagesPlungerGen get plunger => const $AssetsImagesPlungerGen(); $AssetsImagesScoreGen get score => const $AssetsImagesScoreGen(); $AssetsImagesSignpostGen get signpost => const $AssetsImagesSignpostGen(); + $AssetsImagesSkillShotGen get skillShot => const $AssetsImagesSkillShotGen(); $AssetsImagesSlingshotGen get slingshot => const $AssetsImagesSlingshotGen(); $AssetsImagesSparkyGen get sparky => const $AssetsImagesSparkyGen(); } @@ -272,6 +273,26 @@ class $AssetsImagesSignpostGen { const AssetGenImage('assets/images/signpost/inactive.png'); } +class $AssetsImagesSkillShotGen { + const $AssetsImagesSkillShotGen(); + + /// File path: assets/images/skill_shot/decal.png + AssetGenImage get decal => + const AssetGenImage('assets/images/skill_shot/decal.png'); + + /// File path: assets/images/skill_shot/dimmed.png + AssetGenImage get dimmed => + const AssetGenImage('assets/images/skill_shot/dimmed.png'); + + /// File path: assets/images/skill_shot/lit.png + AssetGenImage get lit => + const AssetGenImage('assets/images/skill_shot/lit.png'); + + /// File path: assets/images/skill_shot/pin.png + AssetGenImage get pin => + const AssetGenImage('assets/images/skill_shot/pin.png'); +} + class $AssetsImagesSlingshotGen { const $AssetsImagesSlingshotGen(); diff --git a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart index 649e804b..06e34199 100644 --- a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart +++ b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_cubit.dart @@ -7,7 +7,7 @@ import 'package:pinball_components/pinball_components.dart'; part 'chrome_dino_state.dart'; class ChromeDinoCubit extends Cubit { - ChromeDinoCubit() : super(const ChromeDinoState.inital()); + ChromeDinoCubit() : super(const ChromeDinoState.initial()); void onOpenMouth() { emit(state.copyWith(isMouthOpen: true)); diff --git a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart index a5d3b183..8ed6fa8c 100644 --- a/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart +++ b/packages/pinball_components/lib/src/components/chrome_dino/cubit/chrome_dino_state.dart @@ -14,7 +14,7 @@ class ChromeDinoState extends Equatable { this.ball, }); - const ChromeDinoState.inital() + const ChromeDinoState.initial() : this( status: ChromeDinoStatus.idle, isMouthOpen: false, diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 5eef3538..db2f7d38 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -29,6 +29,7 @@ export 'rocket.dart'; export 'score_component.dart'; export 'shapes/shapes.dart'; export 'signpost/signpost.dart'; +export 'skill_shot/skill_shot.dart'; export 'slingshot.dart'; export 'spaceship_rail.dart'; export 'spaceship_ramp/spaceship_ramp.dart'; diff --git a/packages/pinball_components/lib/src/components/skill_shot/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/skill_shot/behaviors/behaviors.dart new file mode 100644 index 00000000..03aa31bd --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/behaviors/behaviors.dart @@ -0,0 +1,2 @@ +export 'skill_shot_ball_contact_behavior.dart'; +export 'skill_shot_blinking_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior.dart b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior.dart new file mode 100644 index 00000000..62e4185f --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior.dart @@ -0,0 +1,16 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class SkillShotBallContactBehavior extends ContactBehavior { + @override + void beginContact(Object other, Contact contact) { + super.beginContact(other, contact); + if (other is! Ball) return; + parent.bloc.onBallContacted(); + parent.firstChild()?.playing = true; + } +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_blinking_behavior.dart b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_blinking_behavior.dart new file mode 100644 index 00000000..ea62fc25 --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/behaviors/skill_shot_blinking_behavior.dart @@ -0,0 +1,44 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template skill_shot_blinking_behavior} +/// Makes a [SkillShot] blink between [SkillShotSpriteState.lit] and +/// [SkillShotSpriteState.dimmed] for a set amount of blinks. +/// {@endtemplate} +class SkillShotBlinkingBehavior extends TimerComponent + with ParentIsA { + /// {@macro skill_shot_blinking_behavior} + SkillShotBlinkingBehavior() : super(period: 0.15); + + final _maxBlinks = 4; + int _blinks = 0; + + void _onNewState(SkillShotState state) { + if (state.isBlinking) { + timer + ..reset() + ..start(); + } + } + + @override + Future onLoad() async { + await super.onLoad(); + timer.stop(); + parent.bloc.stream.listen(_onNewState); + } + + @override + void onTick() { + super.onTick(); + if (_blinks != _maxBlinks * 2) { + parent.bloc.switched(); + _blinks++; + } else { + _blinks = 0; + timer.stop(); + parent.bloc.onBlinkingFinished(); + } + } +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_cubit.dart b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_cubit.dart new file mode 100644 index 00000000..b9491385 --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_cubit.dart @@ -0,0 +1,39 @@ +// ignore_for_file: public_member_api_docs + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'skill_shot_state.dart'; + +class SkillShotCubit extends Cubit { + SkillShotCubit() : super(const SkillShotState.initial()); + + void onBallContacted() { + emit( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ); + } + + void switched() { + switch (state.spriteState) { + case SkillShotSpriteState.lit: + emit(state.copyWith(spriteState: SkillShotSpriteState.dimmed)); + break; + case SkillShotSpriteState.dimmed: + emit(state.copyWith(spriteState: SkillShotSpriteState.lit)); + break; + } + } + + void onBlinkingFinished() { + emit( + const SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ), + ); + } +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_state.dart b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_state.dart new file mode 100644 index 00000000..1e040db6 --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/cubit/skill_shot_state.dart @@ -0,0 +1,37 @@ +// ignore_for_file: public_member_api_docs + +part of 'skill_shot_cubit.dart'; + +enum SkillShotSpriteState { + lit, + dimmed, +} + +class SkillShotState extends Equatable { + const SkillShotState({ + required this.spriteState, + required this.isBlinking, + }); + + const SkillShotState.initial() + : this( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ); + + final SkillShotSpriteState spriteState; + + final bool isBlinking; + + SkillShotState copyWith({ + SkillShotSpriteState? spriteState, + bool? isBlinking, + }) => + SkillShotState( + spriteState: spriteState ?? this.spriteState, + isBlinking: isBlinking ?? this.isBlinking, + ); + + @override + List get props => [spriteState, isBlinking]; +} diff --git a/packages/pinball_components/lib/src/components/skill_shot/skill_shot.dart b/packages/pinball_components/lib/src/components/skill_shot/skill_shot.dart new file mode 100644 index 00000000..3bf10a7e --- /dev/null +++ b/packages/pinball_components/lib/src/components/skill_shot/skill_shot.dart @@ -0,0 +1,169 @@ +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/skill_shot/behaviors/behaviors.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'cubit/skill_shot_cubit.dart'; + +/// {@template skill_shot} +/// Rollover awarding extra points. +/// {@endtemplate} +class SkillShot extends BodyComponent with ZIndex { + /// {@macro skill_shot} + SkillShot({Iterable? children}) + : this._( + children: children, + bloc: SkillShotCubit(), + ); + + SkillShot._({ + Iterable? children, + required this.bloc, + }) : super( + renderBody: false, + children: [ + SkillShotBallContactBehavior(), + SkillShotBlinkingBehavior(), + _RolloverDecalSpriteComponent(), + PinSpriteAnimationComponent(), + _TextDecalSpriteGroupComponent(state: bloc.state.spriteState), + ...?children, + ], + ) { + zIndex = ZIndexes.decal; + } + + /// Creates a [SkillShot] without any children. + /// + /// This can be used for testing [SkillShot]'s behaviors in isolation. + // TODO(alestiago): Refactor injecting bloc once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + @visibleForTesting + SkillShot.test({ + required this.bloc, + }); + + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + final SkillShotCubit bloc; + + @override + void onRemove() { + bloc.close(); + super.onRemove(); + } + + @override + Body createBody() { + final shape = PolygonShape() + ..setAsBox( + 0.1, + 3.7, + Vector2(-31.9, 9.1), + 0.11, + ); + final fixtureDef = FixtureDef(shape, isSensor: true); + return world.createBody(BodyDef())..createFixture(fixtureDef); + } +} + +class _RolloverDecalSpriteComponent extends SpriteComponent with HasGameRef { + _RolloverDecalSpriteComponent() + : super( + anchor: Anchor.center, + position: Vector2(-31.9, 9.1), + angle: 0.11, + ); + + @override + Future onLoad() async { + await super.onLoad(); + + final sprite = Sprite( + gameRef.images.fromCache( + Assets.images.skillShot.decal.keyName, + ), + ); + this.sprite = sprite; + size = sprite.originalSize / 20; + } +} + +/// {@template pin_sprite_animation_component} +/// Animation for pin in [SkillShot] rollover. +/// {@endtemplate} +@visibleForTesting +class PinSpriteAnimationComponent extends SpriteAnimationComponent + with HasGameRef { + /// {@macro pin_sprite_animation_component} + PinSpriteAnimationComponent() + : super( + anchor: Anchor.center, + position: Vector2(-31.9, 9.1), + angle: 0, + playing: false, + ); + + @override + Future onLoad() async { + await super.onLoad(); + + final spriteSheet = gameRef.images.fromCache( + Assets.images.skillShot.pin.keyName, + ); + + const amountPerRow = 3; + const amountPerColumn = 1; + final textureSize = Vector2( + spriteSheet.width / amountPerRow, + spriteSheet.height / amountPerColumn, + ); + size = textureSize / 10; + + animation = SpriteAnimation.fromFrameData( + spriteSheet, + SpriteAnimationData.sequenced( + amount: amountPerRow * amountPerColumn, + amountPerRow: amountPerRow, + stepTime: 1 / 24, + textureSize: textureSize, + loop: false, + ), + )..onComplete = () { + animation?.reset(); + playing = false; + }; + } +} + +class _TextDecalSpriteGroupComponent + extends SpriteGroupComponent + with HasGameRef, ParentIsA { + _TextDecalSpriteGroupComponent({ + required SkillShotSpriteState state, + }) : super( + anchor: Anchor.center, + position: Vector2(-35.55, 3.59), + current: state, + ); + + @override + Future onLoad() async { + await super.onLoad(); + parent.bloc.stream.listen((state) => current = state.spriteState); + + final sprites = { + SkillShotSpriteState.lit: Sprite( + gameRef.images.fromCache(Assets.images.skillShot.lit.keyName), + ), + SkillShotSpriteState.dimmed: Sprite( + gameRef.images.fromCache(Assets.images.skillShot.dimmed.keyName), + ), + }; + this.sprites = sprites; + size = sprites[current]!.originalSize / 10; + } +} diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index bee6fd02..4f66c220 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -89,6 +89,7 @@ flutter: - assets/images/score/ - assets/images/backbox/ - assets/images/flapper/ + - assets/images/skill_shot/ flutter_gen: line_length: 80 diff --git a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart index 9b6a05b6..4b34940c 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/behaviors/chrome_dino_swiveling_behavior_test.dart @@ -36,7 +36,7 @@ void main() { whenListen( bloc, const Stream.empty(), - initialState: const ChromeDinoState.inital(), + initialState: const ChromeDinoState.initial(), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -58,7 +58,7 @@ void main() { whenListen( bloc, const Stream.empty(), - initialState: const ChromeDinoState.inital(), + initialState: const ChromeDinoState.initial(), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -91,7 +91,7 @@ void main() { bloc, const Stream.empty(), initialState: - const ChromeDinoState.inital().copyWith(isMouthOpen: true), + const ChromeDinoState.initial().copyWith(isMouthOpen: true), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -120,7 +120,7 @@ void main() { bloc, const Stream.empty(), initialState: - const ChromeDinoState.inital().copyWith(isMouthOpen: false), + const ChromeDinoState.initial().copyWith(isMouthOpen: false), ); final chromeDino = ChromeDino.test(bloc: bloc); @@ -148,7 +148,7 @@ void main() { bloc, const Stream.empty(), initialState: - const ChromeDinoState.inital().copyWith(isMouthOpen: false), + const ChromeDinoState.initial().copyWith(isMouthOpen: false), ); final chromeDino = ChromeDino.test(bloc: bloc); diff --git a/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart b/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart index 4c1802ef..d6366092 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/chrome_dino_test.dart @@ -79,7 +79,7 @@ void main() { whenListen( bloc, const Stream.empty(), - initialState: const ChromeDinoState.inital(), + initialState: const ChromeDinoState.initial(), ); when(bloc.close).thenAnswer((_) async {}); final chromeDino = ChromeDino.test(bloc: bloc); diff --git a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart index 79375a6e..80c01983 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_cubit_test.dart @@ -57,7 +57,7 @@ void main() { blocTest( 'onChomp emits nothing when the ball is already in the mouth', build: ChromeDinoCubit.new, - seed: () => const ChromeDinoState.inital().copyWith(ball: ball), + seed: () => const ChromeDinoState.initial().copyWith(ball: ball), act: (bloc) => bloc.onChomp(ball), expect: () => [], ); diff --git a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart index 442d544b..0d7f9c83 100644 --- a/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart +++ b/packages/pinball_components/test/src/components/chrome_dino/cubit/chrome_dino_state_test.dart @@ -36,7 +36,7 @@ void main() { status: ChromeDinoStatus.idle, isMouthOpen: false, ); - expect(ChromeDinoState.inital(), equals(initialState)); + expect(ChromeDinoState.initial(), equals(initialState)); }); }); diff --git a/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior_test.dart new file mode 100644 index 00000000..48a151a3 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_ball_contact_behavior_test.dart @@ -0,0 +1,62 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/skill_shot/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockBall extends Mock implements Ball {} + +class _MockContact extends Mock implements Contact {} + +class _MockSkillShotCubit extends Mock implements SkillShotCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'SkillShotBallContactBehavior', + () { + test('can be instantiated', () { + expect( + SkillShotBallContactBehavior(), + isA(), + ); + }); + + flameTester.testGameWidget( + 'beginContact animates pin and calls onBallContacted ' + 'when contacts with a ball', + setUp: (game, tester) async { + await game.images.load(Assets.images.skillShot.pin.keyName); + final behavior = SkillShotBallContactBehavior(); + final bloc = _MockSkillShotCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.addAll([behavior, PinSpriteAnimationComponent()]); + await game.ensureAdd(skillShot); + + behavior.beginContact(_MockBall(), _MockContact()); + await tester.pump(); + + expect( + skillShot.firstChild()!.playing, + isTrue, + ); + verify(skillShot.bloc.onBallContacted).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_blinking_behavior_test.dart b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_blinking_behavior_test.dart new file mode 100644 index 00000000..e2d00f61 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/behaviors/skill_shot_blinking_behavior_test.dart @@ -0,0 +1,125 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/skill_shot/behaviors/behaviors.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockSkillShotCubit extends Mock implements SkillShotCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group( + 'SkillShotBlinkingBehavior', + () { + flameTester.testGameWidget( + 'calls switched after 0.15 seconds when isBlinking and lit', + setUp: (game, tester) async { + final behavior = SkillShotBlinkingBehavior(); + final bloc = _MockSkillShotCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.add(behavior); + await game.ensureAdd(skillShot); + + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ); + await tester.pump(); + game.update(0.15); + + await streamController.close(); + verify(bloc.switched).called(1); + }, + ); + + flameTester.testGameWidget( + 'calls switched after 0.15 seconds when isBlinking and dimmed', + setUp: (game, tester) async { + final behavior = SkillShotBlinkingBehavior(); + final bloc = _MockSkillShotCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.add(behavior); + await game.ensureAdd(skillShot); + + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: true, + ), + ); + await tester.pump(); + game.update(0.15); + + await streamController.close(); + verify(bloc.switched).called(1); + }, + ); + + flameTester.testGameWidget( + 'calls onBlinkingFinished after all blinks complete', + setUp: (game, tester) async { + final behavior = SkillShotBlinkingBehavior(); + final bloc = _MockSkillShotCubit(); + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: const SkillShotState.initial(), + ); + + final skillShot = SkillShot.test(bloc: bloc); + await skillShot.add(behavior); + await game.ensureAdd(skillShot); + + for (var i = 0; i <= 8; i++) { + if (i.isEven) { + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ); + } else { + streamController.add( + const SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: true, + ), + ); + } + await tester.pump(); + game.update(0.15); + } + + await streamController.close(); + verify(bloc.onBlinkingFinished).called(1); + }, + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_cubit_test.dart b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_cubit_test.dart new file mode 100644 index 00000000..b165db99 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_cubit_test.dart @@ -0,0 +1,66 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group( + 'SkillShotCubit', + () { + blocTest( + 'onBallContacted emits lit and true', + build: SkillShotCubit.new, + act: (bloc) => bloc.onBallContacted(), + expect: () => [ + SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ], + ); + + blocTest( + 'switched emits lit when dimmed', + build: SkillShotCubit.new, + act: (bloc) => bloc.switched(), + expect: () => [ + isA().having( + (state) => state.spriteState, + 'spriteState', + SkillShotSpriteState.lit, + ) + ], + ); + + blocTest( + 'switched emits dimmed when lit', + build: SkillShotCubit.new, + seed: () => SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: false, + ), + act: (bloc) => bloc.switched(), + expect: () => [ + isA().having( + (state) => state.spriteState, + 'spriteState', + SkillShotSpriteState.dimmed, + ) + ], + ); + + blocTest( + 'onBlinkingFinished emits dimmed and false', + build: SkillShotCubit.new, + act: (bloc) => bloc.onBlinkingFinished(), + expect: () => [ + SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ), + ], + ); + }, + ); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_state_test.dart b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_state_test.dart new file mode 100644 index 00000000..ee6e3e0d --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/cubit/skill_shot_state_test.dart @@ -0,0 +1,84 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group('SkillShotState', () { + test('supports value equality', () { + expect( + SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + equals( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + ), + ); + }); + + group('constructor', () { + test('can be instantiated', () { + expect( + const SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ), + isNotNull, + ); + }); + + test('initial is idle with mouth closed', () { + const initialState = SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ); + expect(SkillShotState.initial(), equals(initialState)); + }); + }); + + group('copyWith', () { + test( + 'copies correctly ' + 'when no argument specified', + () { + const chromeDinoState = SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ); + expect( + chromeDinoState.copyWith(), + equals(chromeDinoState), + ); + }, + ); + + test( + 'copies correctly ' + 'when all arguments specified', + () { + const chromeDinoState = SkillShotState( + spriteState: SkillShotSpriteState.lit, + isBlinking: true, + ); + final otherSkillShotState = SkillShotState( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ); + expect(chromeDinoState, isNot(equals(otherSkillShotState))); + + expect( + chromeDinoState.copyWith( + spriteState: SkillShotSpriteState.dimmed, + isBlinking: false, + ), + equals(otherSkillShotState), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/skill_shot/skill_shot_test.dart b/packages/pinball_components/test/src/components/skill_shot/skill_shot_test.dart new file mode 100644 index 00000000..dabacc69 --- /dev/null +++ b/packages/pinball_components/test/src/components/skill_shot/skill_shot_test.dart @@ -0,0 +1,99 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/skill_shot/behaviors/behaviors.dart'; + +import '../../../helpers/helpers.dart'; + +class _MockSkillShotCubit extends Mock implements SkillShotCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final assets = [ + Assets.images.skillShot.decal.keyName, + Assets.images.skillShot.pin.keyName, + Assets.images.skillShot.lit.keyName, + Assets.images.skillShot.dimmed.keyName, + ]; + final flameTester = FlameTester(() => TestGame(assets)); + + group('SkillShot', () { + flameTester.test('loads correctly', (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + expect(game.contains(skillShot), isTrue); + }); + + // TODO(alestiago): Consider refactoring once the following is merged: + // https://github.com/flame-engine/flame/pull/1538 + // ignore: public_member_api_docs + flameTester.test('closes bloc when removed', (game) async { + final bloc = _MockSkillShotCubit(); + whenListen( + bloc, + const Stream.empty(), + initialState: const SkillShotState.initial(), + ); + when(bloc.close).thenAnswer((_) async {}); + final skillShot = SkillShot.test(bloc: bloc); + + await game.ensureAdd(skillShot); + game.remove(skillShot); + await game.ready(); + + verify(bloc.close).called(1); + }); + + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final skillShot = SkillShot( + children: [component], + ); + await game.ensureAdd(skillShot); + expect(skillShot.children, contains(component)); + }); + + flameTester.test('a SkillShotBallContactBehavior', (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + expect( + skillShot.children.whereType().single, + isNotNull, + ); + }); + + flameTester.test('a SkillShotBlinkingBehavior', (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + expect( + skillShot.children.whereType().single, + isNotNull, + ); + }); + }); + + flameTester.test( + 'pin stops animating after animation completes', + (game) async { + final skillShot = SkillShot(); + await game.ensureAdd(skillShot); + + final pinSpriteAnimationComponent = + skillShot.firstChild()!; + + pinSpriteAnimationComponent.playing = true; + game.update( + pinSpriteAnimationComponent.animation!.totalDuration() + 0.1, + ); + + expect(pinSpriteAnimationComponent.playing, isFalse); + }, + ); + }); +} diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index b75b3147..e1ed3084 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -136,6 +136,10 @@ void main() { Assets.images.flapper.flap.keyName, Assets.images.flapper.backSupport.keyName, Assets.images.flapper.frontSupport.keyName, + Assets.images.skillShot.decal.keyName, + Assets.images.skillShot.pin.keyName, + Assets.images.skillShot.lit.keyName, + Assets.images.skillShot.dimmed.keyName, ]; late GameBloc gameBloc; @@ -195,13 +199,16 @@ void main() { }, ); - flameBlocTester.test('has one FlutterForest', (game) async { - await game.ready(); - expect( - game.descendants().whereType().length, - equals(1), - ); - }); + flameBlocTester.test( + 'has one FlutterForest', + (game) async { + await game.ready(); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); flameBlocTester.test( 'has only one Multiballs', @@ -226,6 +233,17 @@ void main() { }, ); + flameBlocTester.test( + 'one SkillShot', + (game) async { + await game.ready(); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + group('controller', () { group('listenWhen', () { flameTester.testGameWidget(