From 11c076c386eb656d674a4ff7b14ea174a999ae04 Mon Sep 17 00:00:00 2001 From: Jorge Coca Date: Mon, 9 May 2022 09:24:59 -0500 Subject: [PATCH 1/7] revert: mobile backgrounds load on mobile (#437) --- .../character_selection_behavior.dart | 16 +++----- lib/game/game_assets.dart | 15 +++----- .../character_selection_behavior_test.dart | 37 +------------------ 3 files changed, 12 insertions(+), 56 deletions(-) diff --git a/lib/game/behaviors/character_selection_behavior.dart b/lib/game/behaviors/character_selection_behavior.dart index e62438f6..27003d75 100644 --- a/lib/game/behaviors/character_selection_behavior.dart +++ b/lib/game/behaviors/character_selection_behavior.dart @@ -2,8 +2,6 @@ import 'package:flame/components.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; -import 'package:platform_helper/platform_helper.dart'; /// Updates the [ArcadeBackground] and launch [Ball] to reflect character /// selections. @@ -13,14 +11,12 @@ class CharacterSelectionBehavior extends Component HasGameRef { @override void onNewState(CharacterThemeState state) { - if (!readProvider().isMobile) { - gameRef - .descendants() - .whereType() - .single - .bloc - .onCharacterSelected(state.characterTheme); - } + gameRef + .descendants() + .whereType() + .single + .bloc + .onCharacterSelected(state.characterTheme); gameRef .descendants() .whereType() diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index fccd494e..0d0ef26a 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -11,8 +11,7 @@ extension PinballGameAssetsX on PinballGame { const sparkyTheme = SparkyTheme(); const androidTheme = AndroidTheme(); const dinoTheme = DinoTheme(); - - final gameAssets = [ + return [ images.load(components.Assets.images.boardBackground.keyName), images.load(components.Assets.images.ball.flameEffect.keyName), images.load(components.Assets.images.signpost.inactive.keyName), @@ -155,14 +154,10 @@ extension PinballGameAssetsX on PinballGame { images.load(dinoTheme.ball.keyName), images.load(sparkyTheme.leaderboardIcon.keyName), images.load(sparkyTheme.ball.keyName), + images.load(androidTheme.background.keyName), + images.load(dashTheme.background.keyName), + images.load(dinoTheme.background.keyName), + images.load(sparkyTheme.background.keyName), ]; - - return (platformHelper.isMobile) ? gameAssets : gameAssets - ..addAll([ - images.load(androidTheme.background.keyName), - images.load(dashTheme.background.keyName), - images.load(dinoTheme.background.keyName), - images.load(sparkyTheme.background.keyName), - ]); } } diff --git a/test/game/behaviors/character_selection_behavior_test.dart b/test/game/behaviors/character_selection_behavior_test.dart index edf17999..5bcd6c50 100644 --- a/test/game/behaviors/character_selection_behavior_test.dart +++ b/test/game/behaviors/character_selection_behavior_test.dart @@ -77,42 +77,7 @@ void main() { ); flameTester.test( - 'onNewState does not call onCharacterSelected on the arcade background ' - 'bloc when platform is mobile', - (game) async { - final platformHelper = _MockPlatformHelper(); - when(() => platformHelper.isMobile).thenAnswer((_) => true); - final arcadeBackgroundBloc = _MockArcadeBackgroundCubit(); - whenListen( - arcadeBackgroundBloc, - const Stream.empty(), - initialState: const ArcadeBackgroundState.initial(), - ); - final behavior = CharacterSelectionBehavior(); - await game.pump( - [ - behavior, - ZCanvasComponent(), - Plunger.test(compressionDistance: 10), - Ball.test(), - ], - platformHelper: platformHelper, - ); - - const dinoThemeState = CharacterThemeState(theme.DinoTheme()); - behavior.onNewState(dinoThemeState); - await game.ready(); - - verifyNever( - () => arcadeBackgroundBloc - .onCharacterSelected(dinoThemeState.characterTheme), - ); - }, - ); - - flameTester.test( - 'onNewState calls onCharacterSelected on the arcade background ' - 'bloc when platform is not mobile', + 'onNewState calls onCharacterSelected on the arcade background bloc', (game) async { final platformHelper = _MockPlatformHelper(); when(() => platformHelper.isMobile).thenAnswer((_) => false); From 461471b01f1066891613533f654c45fddd926d7b Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Mon, 9 May 2022 16:09:06 +0100 Subject: [PATCH 2/7] refactor: implemented `Plunger` behaviors (#434) * feat: defined Plunger behaviors * refactor: removed ComponentController * refactor: implementing plunger behaviors * feat: tested plunger behaviors * feat: applied Plunger behaviours depending on platfotm * refactor: fixed typos * test: updated tap * refactor: removed key_testers * refactor: PR typos * test: added strength assertions * test: updated goldens * refactor: renamed methods * refactor: fixed typo * refactor: removed dead file --- lib/game/components/components.dart | 1 - lib/game/components/controlled_plunger.dart | 76 ---- .../components/game_bloc_status_listener.dart | 52 ++- lib/game/components/launcher.dart | 4 +- lib/game/pinball_game.dart | 12 +- .../lib/src/components/bumping_behavior.dart | 4 +- .../lib/src/components/components.dart | 2 +- .../flipper_key_controlling_behavior.dart | 33 +- .../lib/src/components/plunger.dart | 251 ----------- .../plunger/behaviors/behaviors.dart | 5 + .../behaviors/plunger_jointing_behavior.dart | 54 +++ .../plunger_key_controlling_behavior.dart | 33 ++ .../behaviors/plunger_noise_behavior.dart | 19 + .../behaviors/plunger_pulling_behavior.dart | 46 +++ .../behaviors/plunger_releasing_behavior.dart | 31 ++ .../plunger/cubit/plunger_cubit.dart | 15 + .../plunger/cubit/plunger_state.dart | 12 + .../lib/src/components/plunger/plunger.dart | 139 +++++++ packages/pinball_components/pubspec.yaml | 2 + .../lib/stories/plunger/plunger_game.dart | 38 +- .../pinball_components/sandbox/pubspec.lock | 84 ++++ .../src/components/bumping_behavior_test.dart | 14 + .../flipper_jointing_behavior_test.dart | 5 +- ...flipper_key_controlling_behavior_test.dart | 5 +- .../src/components/golden/plunger/pull.png | Bin 40669 -> 41522 bytes .../src/components/golden/plunger/release.png | Bin 41527 -> 40665 bytes .../plunger_jointing_behavior_test.dart | 36 ++ ...plunger_key_controlling_behavior_test.dart | 194 +++++++++ .../plunger_noise_behavior_test.dart | 91 ++++ .../plunger_pulling_behavior_test.dart | 160 +++++++ .../plunger_releasing_behavior_test.dart | 79 ++++ .../src/components/plunger/plunger_test.dart | 116 ++++++ .../test/src/components/plunger_test.dart | 391 ------------------ packages/pinball_flame/lib/pinball_flame.dart | 1 - .../lib/src/component_controller.dart | 41 -- .../test/src/component_controller_test.dart | 96 ----- .../ball_spawning_behavior_test.dart | 2 +- .../character_selection_behavior_test.dart | 7 +- .../components/controlled_plunger_test.dart | 185 --------- .../game_bloc_status_listener_test.dart | 198 ++++++++- test/game/pinball_game_test.dart | 13 +- test/helpers/helpers.dart | 1 - test/helpers/key_testers.dart | 50 --- 43 files changed, 1417 insertions(+), 1181 deletions(-) delete mode 100644 lib/game/components/controlled_plunger.dart delete mode 100644 packages/pinball_components/lib/src/components/plunger.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/behaviors/behaviors.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/behaviors/plunger_jointing_behavior.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/behaviors/plunger_key_controlling_behavior.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/behaviors/plunger_noise_behavior.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/behaviors/plunger_pulling_behavior.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/behaviors/plunger_releasing_behavior.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/cubit/plunger_cubit.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/cubit/plunger_state.dart create mode 100644 packages/pinball_components/lib/src/components/plunger/plunger.dart create mode 100644 packages/pinball_components/test/src/components/plunger/behaviors/plunger_jointing_behavior_test.dart create mode 100644 packages/pinball_components/test/src/components/plunger/behaviors/plunger_key_controlling_behavior_test.dart create mode 100644 packages/pinball_components/test/src/components/plunger/behaviors/plunger_noise_behavior_test.dart create mode 100644 packages/pinball_components/test/src/components/plunger/behaviors/plunger_pulling_behavior_test.dart create mode 100644 packages/pinball_components/test/src/components/plunger/behaviors/plunger_releasing_behavior_test.dart create mode 100644 packages/pinball_components/test/src/components/plunger/plunger_test.dart delete mode 100644 packages/pinball_components/test/src/components/plunger_test.dart delete mode 100644 packages/pinball_flame/lib/src/component_controller.dart delete mode 100644 packages/pinball_flame/test/src/component_controller_test.dart delete mode 100644 test/game/components/controlled_plunger_test.dart delete mode 100644 test/helpers/key_testers.dart diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 969ea1ac..103f029c 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,7 +1,6 @@ export 'android_acres/android_acres.dart'; export 'backbox/backbox.dart'; export 'bottom_group.dart'; -export 'controlled_plunger.dart'; export 'dino_desert/dino_desert.dart'; export 'drain/drain.dart'; export 'flutter_forest/flutter_forest.dart'; diff --git a/lib/game/components/controlled_plunger.dart b/lib/game/components/controlled_plunger.dart deleted file mode 100644 index f709de66..00000000 --- a/lib/game/components/controlled_plunger.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame_bloc/flame_bloc.dart'; -import 'package:flutter/services.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_audio/pinball_audio.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// {@template controlled_plunger} -/// A [Plunger] with a [PlungerController] attached. -/// {@endtemplate} -class ControlledPlunger extends Plunger with Controls { - /// {@macro controlled_plunger} - ControlledPlunger({required double compressionDistance}) - : super(compressionDistance: compressionDistance) { - controller = PlungerController(this); - } - - @override - void release() { - super.release(); - - add(PlungerNoiseBehavior()); - } -} - -/// A behavior attached to the plunger when it launches the ball which plays the -/// related sound effects. -class PlungerNoiseBehavior extends Component { - @override - Future onLoad() async { - await super.onLoad(); - readProvider().play(PinballAudio.launcher); - } - - @override - void update(double dt) { - super.update(dt); - removeFromParent(); - } -} - -/// {@template plunger_controller} -/// A [ComponentController] that controls a [Plunger]s movement. -/// {@endtemplate} -class PlungerController extends ComponentController - with KeyboardHandler, FlameBlocReader { - /// {@macro plunger_controller} - PlungerController(Plunger plunger) : super(plunger); - - /// The [LogicalKeyboardKey]s that will control the [Flipper]. - /// - /// [onKeyEvent] method listens to when one of these keys is pressed. - static const List _keys = [ - LogicalKeyboardKey.arrowDown, - LogicalKeyboardKey.space, - LogicalKeyboardKey.keyS, - ]; - - @override - bool onKeyEvent( - RawKeyEvent event, - Set keysPressed, - ) { - if (bloc.state.status.isGameOver) return true; - if (!_keys.contains(event.logicalKey)) return true; - - if (event is RawKeyDownEvent) { - component.pull(); - } else if (event is RawKeyUpEvent) { - component.release(); - } - - return false; - } -} diff --git a/lib/game/components/game_bloc_status_listener.dart b/lib/game/components/game_bloc_status_listener.dart index 1a5a06df..359db070 100644 --- a/lib/game/components/game_bloc_status_listener.dart +++ b/lib/game/components/game_bloc_status_listener.dart @@ -5,6 +5,7 @@ import 'package:pinball/select_character/select_character.dart'; import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; +import 'package:platform_helper/platform_helper.dart'; /// Listens to the [GameBloc] and updates the game accordingly. class GameBlocStatusListener extends Component @@ -24,7 +25,11 @@ class GameBlocStatusListener extends Component gameRef .descendants() .whereType() - .forEach(_addFlipperKeyControls); + .forEach(_addFlipperBehaviors); + gameRef + .descendants() + .whereType() + .forEach(_addPlungerBehaviors); gameRef.overlays.remove(PinballGame.playButtonOverlay); break; @@ -40,18 +45,51 @@ class GameBlocStatusListener extends Component gameRef .descendants() .whereType() - .forEach(_removeFlipperKeyControls); + .forEach(_removeFlipperBehaviors); + gameRef + .descendants() + .whereType() + .forEach(_removePlungerBehaviors); break; } } - void _addFlipperKeyControls(Flipper flipper) { - flipper - ..add(FlipperKeyControllingBehavior()) - ..moveDown(); + void _addPlungerBehaviors(Plunger plunger) { + final platformHelper = readProvider(); + const pullingStrength = 7.0; + final provider = + plunger.firstChild>()!; + + if (platformHelper.isMobile) { + provider.add( + PlungerAutoPullingBehavior(strength: pullingStrength), + ); + } else { + provider.addAll( + [ + PlungerKeyControllingBehavior(), + PlungerPullingBehavior(strength: pullingStrength), + ], + ); + } + } + + void _removePlungerBehaviors(Plunger plunger) { + plunger + .descendants() + .whereType() + .forEach(plunger.remove); + plunger + .descendants() + .whereType() + .forEach(plunger.remove); } - void _removeFlipperKeyControls(Flipper flipper) => flipper + void _addFlipperBehaviors(Flipper flipper) => flipper + ..add(FlipperKeyControllingBehavior()) + ..moveDown(); + + void _removeFlipperBehaviors(Flipper flipper) => flipper .descendants() .whereType() .forEach(flipper.remove); diff --git a/lib/game/components/launcher.dart b/lib/game/components/launcher.dart index 4729515a..99b44a80 100644 --- a/lib/game/components/launcher.dart +++ b/lib/game/components/launcher.dart @@ -1,5 +1,4 @@ import 'package:flame/components.dart'; -import 'package:pinball/game/components/components.dart'; import 'package:pinball_components/pinball_components.dart' hide Assets; /// {@template launcher} @@ -13,8 +12,7 @@ class Launcher extends Component { children: [ LaunchRamp(), Flapper(), - ControlledPlunger(compressionDistance: 9.2) - ..initialPosition = Vector2(41, 43.7), + Plunger()..initialPosition = Vector2(41, 43.7), RocketSpriteComponent()..position = Vector2(42.8, 62.3), ], ); diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index b1f3c98a..2250a8fa 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -156,9 +156,15 @@ class PinballGame extends PinballForge2DGame final rocket = descendants().whereType().first; final bounds = rocket.topLeftPosition & rocket.size; - // NOTE: As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 we need to check it at the highest level manually. - if (bounds.contains(info.eventPosition.game.toOffset())) { - descendants().whereType().single.pullFor(2); + // NOTE: As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 + // we need to check it at the highest level manually. + final tappedRocket = bounds.contains(info.eventPosition.game.toOffset()); + if (tappedRocket) { + descendants() + .whereType>() + .first + .bloc + .pulled(); } else { final leftSide = info.eventPosition.widget.x < canvasSize.x / 2; focusedBoardSide[pointerId] = diff --git a/packages/pinball_components/lib/src/components/bumping_behavior.dart b/packages/pinball_components/lib/src/components/bumping_behavior.dart index 17931838..0d259860 100644 --- a/packages/pinball_components/lib/src/components/bumping_behavior.dart +++ b/packages/pinball_components/lib/src/components/bumping_behavior.dart @@ -7,7 +7,9 @@ import 'package:pinball_flame/pinball_flame.dart'; /// {@endtemplate} class BumpingBehavior extends ContactBehavior { /// {@macro bumping_behavior} - BumpingBehavior({required double strength}) : _strength = strength; + BumpingBehavior({required double strength}) + : assert(strength >= 0, "Strength can't be negative."), + _strength = strength; /// Determines how strong the bump is. final double _strength; diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 1116ee88..8fd74268 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -27,7 +27,7 @@ export 'launch_ramp.dart'; export 'layer_sensor/layer_sensor.dart'; export 'multiball/multiball.dart'; export 'multiplier/multiplier.dart'; -export 'plunger.dart'; +export 'plunger/plunger.dart'; export 'rocket.dart'; export 'score_component/score_component.dart'; export 'signpost/signpost.dart'; diff --git a/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart b/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart index 95566e75..ca4fcece 100644 --- a/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart +++ b/packages/pinball_components/lib/src/components/flipper/behaviors/flipper_key_controlling_behavior.dart @@ -14,7 +14,21 @@ class FlipperKeyControllingBehavior extends Component @override Future onLoad() async { await super.onLoad(); - _keys = parent.side.flipperKeys; + + switch (parent.side) { + case BoardSide.left: + _keys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.keyA, + ]; + break; + case BoardSide.right: + _keys = [ + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.keyD, + ]; + break; + } } @override @@ -33,20 +47,3 @@ class FlipperKeyControllingBehavior extends Component return false; } } - -extension on BoardSide { - List get flipperKeys { - switch (this) { - case BoardSide.left: - return [ - LogicalKeyboardKey.arrowLeft, - LogicalKeyboardKey.keyA, - ]; - case BoardSide.right: - return [ - LogicalKeyboardKey.arrowRight, - LogicalKeyboardKey.keyD, - ]; - } - } -} diff --git a/packages/pinball_components/lib/src/components/plunger.dart b/packages/pinball_components/lib/src/components/plunger.dart deleted file mode 100644 index 6f38eb37..00000000 --- a/packages/pinball_components/lib/src/components/plunger.dart +++ /dev/null @@ -1,251 +0,0 @@ -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_flame/pinball_flame.dart'; - -/// {@template plunger} -/// [Plunger] serves as a spring, that shoots the ball on the right side of the -/// play field. -/// -/// [Plunger] ignores gravity so the player controls its downward [pull]. -/// {@endtemplate} -class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex { - /// {@macro plunger} - Plunger({ - required this.compressionDistance, - }) : super( - renderBody: false, - children: [_PlungerSpriteAnimationGroupComponent()], - ) { - zIndex = ZIndexes.plunger; - layer = Layer.launcher; - } - - /// Creates a [Plunger] without any children. - /// - /// This can be used for testing [Plunger]'s behaviors in isolation. - @visibleForTesting - Plunger.test({required this.compressionDistance}); - - /// Distance the plunger can lower. - final double compressionDistance; - - List _createFixtureDefs() { - final fixturesDef = []; - - final leftShapeVertices = [ - Vector2(0, 0), - Vector2(-1.8, 0), - Vector2(-1.8, -2.2), - Vector2(0, -0.3), - ]..map((vector) => vector.rotate(BoardDimensions.perspectiveAngle)) - .toList(); - final leftTriangleShape = PolygonShape()..set(leftShapeVertices); - - final leftTriangleFixtureDef = FixtureDef(leftTriangleShape)..density = 80; - fixturesDef.add(leftTriangleFixtureDef); - - final rightShapeVertices = [ - Vector2(0, 0), - Vector2(1.8, 0), - Vector2(1.8, -2.2), - Vector2(0, -0.3), - ]..map((vector) => vector.rotate(BoardDimensions.perspectiveAngle)) - .toList(); - final rightTriangleShape = PolygonShape()..set(rightShapeVertices); - - final rightTriangleFixtureDef = FixtureDef(rightTriangleShape) - ..density = 80; - fixturesDef.add(rightTriangleFixtureDef); - - return fixturesDef; - } - - @override - Body createBody() { - final bodyDef = BodyDef( - position: initialPosition, - userData: this, - type: BodyType.dynamic, - gravityScale: Vector2.zero(), - ); - - final body = world.createBody(bodyDef); - _createFixtureDefs().forEach(body.createFixture); - return body; - } - - var _pullingDownTime = 0.0; - - /// Pulls the plunger down for the given amount of [seconds]. - // ignore: use_setters_to_change_properties - void pullFor(double seconds) { - _pullingDownTime = seconds; - } - - /// Set a constant downward velocity on the [Plunger]. - void pull() { - final sprite = firstChild<_PlungerSpriteAnimationGroupComponent>()!; - - body.linearVelocity = Vector2(0, 7); - sprite.pull(); - } - - /// Set an upward velocity on the [Plunger]. - /// - /// The velocity's magnitude depends on how far the [Plunger] has been pulled - /// from its original [initialPosition]. - void release() { - final sprite = firstChild<_PlungerSpriteAnimationGroupComponent>()!; - - _pullingDownTime = 0; - final velocity = (initialPosition.y - body.position.y) * 11; - body.linearVelocity = Vector2(0, velocity); - sprite.release(); - } - - @override - void update(double dt) { - // Ensure that we only pull or release when the time is greater than zero. - if (_pullingDownTime > 0) { - _pullingDownTime -= PinballForge2DGame.clampDt(dt); - if (_pullingDownTime <= 0) { - release(); - } else { - pull(); - } - } - super.update(dt); - } - - /// Anchors the [Plunger] to the [PrismaticJoint] that controls its vertical - /// motion. - Future _anchorToJoint() async { - final anchor = PlungerAnchor(plunger: this); - await add(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: this, - anchor: anchor, - ); - - world.createJoint( - PrismaticJoint(jointDef)..setLimits(-compressionDistance, 0), - ); - } - - @override - Future onLoad() async { - await super.onLoad(); - await _anchorToJoint(); - } -} - -/// Animation states associated with a [Plunger]. -enum _PlungerAnimationState { - /// Pull state. - pull, - - /// Release state. - release, -} - -/// Animations for pulling and releasing [Plunger]. -class _PlungerSpriteAnimationGroupComponent - extends SpriteAnimationGroupComponent<_PlungerAnimationState> - with HasGameRef { - _PlungerSpriteAnimationGroupComponent() - : super( - anchor: Anchor.center, - position: Vector2(1.87, 14.9), - ); - - void pull() { - if (current != _PlungerAnimationState.pull) { - animation?.reset(); - } - current = _PlungerAnimationState.pull; - } - - void release() { - if (current != _PlungerAnimationState.release) { - animation?.reset(); - } - current = _PlungerAnimationState.release; - } - - @override - Future onLoad() async { - await super.onLoad(); - final spriteSheet = await gameRef.images.load( - Assets.images.plunger.plunger.keyName, - ); - const amountPerRow = 20; - const amountPerColumn = 1; - final textureSize = Vector2( - spriteSheet.width / amountPerRow, - spriteSheet.height / amountPerColumn, - ); - size = textureSize / 10; - final pullAnimation = SpriteAnimation.fromFrameData( - spriteSheet, - SpriteAnimationData.sequenced( - amount: amountPerRow * amountPerColumn ~/ 2, - amountPerRow: amountPerRow ~/ 2, - stepTime: 1 / 24, - textureSize: textureSize, - texturePosition: Vector2.zero(), - loop: false, - ), - ); - animations = { - _PlungerAnimationState.release: pullAnimation.reversed(), - _PlungerAnimationState.pull: pullAnimation, - }; - current = _PlungerAnimationState.release; - } -} - -/// {@template plunger_anchor} -/// [JointAnchor] positioned below a [Plunger]. -/// {@endtemplate} -class PlungerAnchor extends JointAnchor { - /// {@macro plunger_anchor} - PlungerAnchor({ - required Plunger plunger, - }) { - initialPosition = Vector2( - 0, - plunger.compressionDistance, - ); - } -} - -/// {@template plunger_anchor_prismatic_joint_def} -/// [PrismaticJointDef] between a [Plunger] and an [JointAnchor] with motion on -/// the vertical axis. -/// -/// The [Plunger] is constrained vertically between its starting position and -/// the [JointAnchor]. The [JointAnchor] must be below the [Plunger]. -/// {@endtemplate} -class PlungerAnchorPrismaticJointDef extends PrismaticJointDef { - /// {@macro plunger_anchor_prismatic_joint_def} - PlungerAnchorPrismaticJointDef({ - required Plunger plunger, - required PlungerAnchor anchor, - }) { - initialize( - plunger.body, - anchor.body, - plunger.body.position + anchor.body.position, - Vector2(16, BoardDimensions.bounds.height), - ); - enableLimit = true; - lowerTranslation = double.negativeInfinity; - enableMotor = true; - motorSpeed = 1000; - maxMotorForce = motorSpeed; - collideConnected = true; - } -} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/behaviors.dart new file mode 100644 index 00000000..0c772a0e --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/behaviors.dart @@ -0,0 +1,5 @@ +export 'plunger_jointing_behavior.dart'; +export 'plunger_key_controlling_behavior.dart'; +export 'plunger_noise_behavior.dart'; +export 'plunger_pulling_behavior.dart'; +export 'plunger_releasing_behavior.dart'; diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_jointing_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_jointing_behavior.dart new file mode 100644 index 00000000..06332bef --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_jointing_behavior.dart @@ -0,0 +1,54 @@ +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 PlungerJointingBehavior extends Component with ParentIsA { + PlungerJointingBehavior({required double compressionDistance}) + : _compressionDistance = compressionDistance; + + final double _compressionDistance; + + @override + Future onLoad() async { + await super.onLoad(); + final anchor = JointAnchor() + ..initialPosition = Vector2(0, _compressionDistance); + await add(anchor); + + final jointDef = _PlungerAnchorPrismaticJointDef( + plunger: parent, + anchor: anchor, + ); + + parent.world.createJoint( + PrismaticJoint(jointDef)..setLimits(-_compressionDistance, 0), + ); + } +} + +/// [PrismaticJointDef] between a [Plunger] and an [JointAnchor] with motion on +/// the vertical axis. +/// +/// The [Plunger] is constrained vertically between its starting position and +/// the [JointAnchor]. The [JointAnchor] must be below the [Plunger]. +class _PlungerAnchorPrismaticJointDef extends PrismaticJointDef { + /// {@macro plunger_anchor_prismatic_joint_def} + _PlungerAnchorPrismaticJointDef({ + required Plunger plunger, + required BodyComponent anchor, + }) { + initialize( + plunger.body, + anchor.body, + plunger.body.position + anchor.body.position, + Vector2(16, BoardDimensions.bounds.height), + ); + enableLimit = true; + lowerTranslation = double.negativeInfinity; + enableMotor = true; + motorSpeed = 1000; + maxMotorForce = motorSpeed; + collideConnected = true; + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_key_controlling_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_key_controlling_behavior.dart new file mode 100644 index 00000000..fcff816a --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_key_controlling_behavior.dart @@ -0,0 +1,33 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flutter/services.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// Allows controlling the [Plunger]'s movement with keyboard input. +class PlungerKeyControllingBehavior extends Component + with KeyboardHandler, FlameBlocReader { + /// The [LogicalKeyboardKey]s that will control the [Plunger]. + /// + /// [onKeyEvent] method listens to when one of these keys is pressed. + static const List _keys = [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyS, + ]; + + @override + bool onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + if (!_keys.contains(event.logicalKey)) return true; + + if (event is RawKeyDownEvent) { + bloc.pulled(); + } else if (event is RawKeyUpEvent) { + bloc.released(); + } + + return false; + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_noise_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_noise_behavior.dart new file mode 100644 index 00000000..96cb9bd2 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_noise_behavior.dart @@ -0,0 +1,19 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball_audio/pinball_audio.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// Plays the [PinballAudio.launcher] sound. +/// +/// It is attached when the plunger is released. +class PlungerNoiseBehavior extends Component + with FlameBlocListenable { + @override + void onNewState(PlungerState state) { + super.onNewState(state); + if (state.isReleasing) { + readProvider().play(PinballAudio.launcher); + } + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_pulling_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_pulling_behavior.dart new file mode 100644 index 00000000..db6bcaa3 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_pulling_behavior.dart @@ -0,0 +1,46 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class PlungerPullingBehavior extends Component + with FlameBlocReader { + PlungerPullingBehavior({ + required double strength, + }) : assert(strength >= 0, "Strength can't be negative."), + _strength = strength; + + final double _strength; + + late final Plunger _plunger; + + @override + Future onLoad() async { + await super.onLoad(); + _plunger = parent!.parent! as Plunger; + } + + @override + void update(double dt) { + if (bloc.state.isPulling) { + _plunger.body.linearVelocity = Vector2(0, _strength); + } + } +} + +class PlungerAutoPullingBehavior extends PlungerPullingBehavior { + PlungerAutoPullingBehavior({ + required double strength, + }) : super(strength: strength); + + @override + void update(double dt) { + super.update(dt); + + final joint = _plunger.body.joints.whereType().single; + final reachedBottom = joint.getJointTranslation() <= joint.getLowerLimit(); + if (reachedBottom) { + bloc.released(); + } + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_releasing_behavior.dart b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_releasing_behavior.dart new file mode 100644 index 00000000..d2935818 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/behaviors/plunger_releasing_behavior.dart @@ -0,0 +1,31 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class PlungerReleasingBehavior extends Component + with FlameBlocListenable { + PlungerReleasingBehavior({ + required double strength, + }) : assert(strength >= 0, "Strength can't be negative."), + _strength = strength; + + final double _strength; + + late final Plunger _plunger; + + @override + Future onLoad() async { + await super.onLoad(); + _plunger = parent!.parent! as Plunger; + } + + @override + void onNewState(PlungerState state) { + super.onNewState(state); + if (state.isReleasing) { + final velocity = + (_plunger.initialPosition.y - _plunger.body.position.y) * _strength; + _plunger.body.linearVelocity = Vector2(0, velocity); + } + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/cubit/plunger_cubit.dart b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_cubit.dart new file mode 100644 index 00000000..601257d2 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_cubit.dart @@ -0,0 +1,15 @@ +import 'package:bloc/bloc.dart'; + +part 'plunger_state.dart'; + +class PlungerCubit extends Cubit { + PlungerCubit() : super(PlungerState.releasing); + + void pulled() { + emit(PlungerState.pulling); + } + + void released() { + emit(PlungerState.releasing); + } +} diff --git a/packages/pinball_components/lib/src/components/plunger/cubit/plunger_state.dart b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_state.dart new file mode 100644 index 00000000..8b82ef96 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/cubit/plunger_state.dart @@ -0,0 +1,12 @@ +part of 'plunger_cubit.dart'; + +enum PlungerState { + pulling, + + releasing, +} + +extension PlungerStateX on PlungerState { + bool get isPulling => this == PlungerState.pulling; + bool get isReleasing => this == PlungerState.releasing; +} diff --git a/packages/pinball_components/lib/src/components/plunger/plunger.dart b/packages/pinball_components/lib/src/components/plunger/plunger.dart new file mode 100644 index 00000000..9f3b6873 --- /dev/null +++ b/packages/pinball_components/lib/src/components/plunger/plunger.dart @@ -0,0 +1,139 @@ +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +export 'behaviors/behaviors.dart'; +export 'cubit/plunger_cubit.dart'; + +/// {@template plunger} +/// [Plunger] serves as a spring, that shoots the ball on the right side of the +/// play field. +/// +/// [Plunger] ignores gravity so the player controls its downward movement. +/// {@endtemplate} +class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex { + /// {@macro plunger} + Plunger() + : super( + renderBody: false, + children: [ + FlameBlocProvider( + create: PlungerCubit.new, + children: [ + _PlungerSpriteAnimationGroupComponent(), + PlungerReleasingBehavior(strength: 11), + PlungerNoiseBehavior(), + ], + ), + PlungerJointingBehavior(compressionDistance: 9.2), + ], + ) { + zIndex = ZIndexes.plunger; + layer = Layer.launcher; + } + + /// Creates a [Plunger] without any children. + /// + /// This can be used for testing [Plunger]'s behaviors in isolation. + @visibleForTesting + Plunger.test(); + + List _createFixtureDefs() { + final leftShapeVertices = [ + Vector2(0, 0), + Vector2(-1.8, 0), + Vector2(-1.8, -2.2), + Vector2(0, -0.3), + ]..forEach((vector) => vector.rotate(BoardDimensions.perspectiveAngle)); + final leftTriangleShape = PolygonShape()..set(leftShapeVertices); + + final rightShapeVertices = [ + Vector2(0, 0), + Vector2(1.8, 0), + Vector2(1.8, -2.2), + Vector2(0, -0.3), + ]..forEach((vector) => vector.rotate(BoardDimensions.perspectiveAngle)); + final rightTriangleShape = PolygonShape()..set(rightShapeVertices); + + return [ + FixtureDef( + leftTriangleShape, + density: 80, + ), + FixtureDef( + rightTriangleShape, + density: 80, + ), + ]; + } + + @override + Body createBody() { + final bodyDef = BodyDef( + position: initialPosition, + type: BodyType.dynamic, + gravityScale: Vector2.zero(), + ); + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + + return body; + } +} + +class _PlungerSpriteAnimationGroupComponent + extends SpriteAnimationGroupComponent + with HasGameRef, FlameBlocListenable { + _PlungerSpriteAnimationGroupComponent() + : super( + anchor: Anchor.center, + position: Vector2(1.87, 14.9), + ); + + @override + void onNewState(PlungerState state) { + super.onNewState(state); + final startedReleasing = state.isReleasing && !current!.isReleasing; + final startedPulling = state.isPulling && !current!.isPulling; + if (startedReleasing || startedPulling) { + animation?.reset(); + } + + current = state; + } + + @override + Future onLoad() async { + await super.onLoad(); + final spriteSheet = await gameRef.images.load( + Assets.images.plunger.plunger.keyName, + ); + const amountPerRow = 20; + const amountPerColumn = 1; + final textureSize = Vector2( + spriteSheet.width / amountPerRow, + spriteSheet.height / amountPerColumn, + ); + size = textureSize / 10; + final pullAnimation = SpriteAnimation.fromFrameData( + spriteSheet, + SpriteAnimationData.sequenced( + amount: amountPerRow * amountPerColumn ~/ 2, + amountPerRow: amountPerRow ~/ 2, + stepTime: 1 / 24, + textureSize: textureSize, + texturePosition: Vector2.zero(), + loop: false, + ), + ); + animations = { + PlungerState.releasing: pullAnimation.reversed(), + PlungerState.pulling: pullAnimation, + }; + + current = readBloc().state; + } +} diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index fe52f8b8..7fe3ac5e 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: flutter: sdk: flutter intl: ^0.17.0 + pinball_audio: + path: ../pinball_audio pinball_flame: path: ../pinball_flame pinball_theme: diff --git a/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart b/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart index 0ee58cc9..50af919f 100644 --- a/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart +++ b/packages/pinball_components/sandbox/lib/stories/plunger/plunger_game.dart @@ -1,11 +1,10 @@ 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/common/common.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart'; -class PlungerGame extends BallGame with KeyboardEvents, Traceable { +class PlungerGame extends BallGame + with HasKeyboardHandlerComponents, Traceable { static const description = ''' Shows how Plunger is rendered. @@ -13,39 +12,16 @@ class PlungerGame extends BallGame with KeyboardEvents, Traceable { - Tap anywhere on the screen to spawn a ball into the game. '''; - static const _downKeys = [ - LogicalKeyboardKey.arrowDown, - LogicalKeyboardKey.space, - ]; - - late Plunger plunger; - @override Future onLoad() async { await super.onLoad(); final center = screenToWorld(camera.viewport.canvasSize! / 2); - await add( - plunger = Plunger(compressionDistance: 29) - ..initialPosition = Vector2(center.x - 8.8, center.y), - ); - await traceAllBodies(); - } + final plunger = Plunger() + ..initialPosition = Vector2(center.x - 8.8, center.y); + await add(plunger); + await plunger.add(PlungerKeyControllingBehavior()); - @override - KeyEventResult onKeyEvent( - RawKeyEvent event, - Set keysPressed, - ) { - final movedPlungerDown = _downKeys.contains(event.logicalKey); - if (movedPlungerDown) { - if (event is RawKeyDownEvent) { - plunger.pull(); - } else if (event is RawKeyUpEvent) { - plunger.release(); - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; + await traceAllBodies(); } } diff --git a/packages/pinball_components/sandbox/pubspec.lock b/packages/pinball_components/sandbox/pubspec.lock index b5ac88b7..9fcb0f89 100644 --- a/packages/pinball_components/sandbox/pubspec.lock +++ b/packages/pinball_components/sandbox/pubspec.lock @@ -15,6 +15,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.2" + audioplayers: + dependency: transitive + description: + name: audioplayers + url: "https://pub.dartlang.org" + source: hosted + version: "0.20.1" bloc: dependency: transitive description: @@ -57,6 +64,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" dashbook: dependency: "direct main" description: @@ -106,6 +120,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + flame_audio: + dependency: transitive + description: + name: flame_audio + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" flame_bloc: dependency: transitive description: @@ -179,6 +200,20 @@ packages: relative: true source: path version: "1.0.0+1" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" intl: dependency: transitive description: @@ -249,6 +284,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + path_provider: + dependency: transitive + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" path_provider_linux: dependency: transitive description: @@ -256,6 +312,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.5" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" path_provider_platform_interface: dependency: transitive description: @@ -270,6 +333,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.5" + pinball_audio: + dependency: transitive + description: + path: "../../pinball_audio" + relative: true + source: path + version: "1.0.0+1" pinball_components: dependency: "direct main" description: @@ -415,6 +485,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+2" term_glyph: dependency: transitive description: @@ -492,6 +569,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.0" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" vector_math: dependency: transitive description: diff --git a/packages/pinball_components/test/src/components/bumping_behavior_test.dart b/packages/pinball_components/test/src/components/bumping_behavior_test.dart index 07e35cca..7a87a46c 100644 --- a/packages/pinball_components/test/src/components/bumping_behavior_test.dart +++ b/packages/pinball_components/test/src/components/bumping_behavior_test.dart @@ -24,6 +24,20 @@ void main() { final flameTester = FlameTester(TestGame.new); group('BumpingBehavior', () { + test('can be instantiated', () { + expect( + BumpingBehavior(strength: 0), + isA(), + ); + }); + + test('throws assertion error when strength is negative ', () { + expect( + () => BumpingBehavior(strength: -1), + throwsAssertionError, + ); + }); + flameTester.test('can be added', (game) async { final behavior = BumpingBehavior(strength: 0); final component = _TestBodyComponent(); diff --git a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart index 3d6c3b83..70c93439 100644 --- a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart +++ b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_jointing_behavior_test.dart @@ -19,19 +19,18 @@ void main() { }); flameTester.test('can be loaded', (game) async { - final behavior = FlipperJointingBehavior(); final parent = Flipper.test(side: BoardSide.left); + final behavior = FlipperJointingBehavior(); await game.ensureAdd(parent); await parent.ensureAdd(behavior); expect(parent.contains(behavior), isTrue); }); flameTester.test('creates a joint', (game) async { - final behavior = FlipperJointingBehavior(); final parent = Flipper.test(side: BoardSide.left); + final behavior = FlipperJointingBehavior(); await game.ensureAdd(parent); await parent.ensureAdd(behavior); - expect(parent.body.joints, isNotEmpty); }); }); diff --git a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart index 11af6187..6a6ac91c 100644 --- a/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart +++ b/packages/pinball_components/test/src/components/flipper/behaviors/flipper_key_controlling_behavior_test.dart @@ -1,5 +1,6 @@ // ignore_for_file: cascade_invocations +import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -8,8 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball_components/pinball_components.dart'; -import '../../../../helpers/helpers.dart'; - class _MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { @@ -27,7 +26,7 @@ class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('FlipperKeyControllingBehavior', () { - final flameTester = FlameTester(TestGame.new); + final flameTester = FlameTester(Forge2DGame.new); group( 'onKeyEvent', diff --git a/packages/pinball_components/test/src/components/golden/plunger/pull.png b/packages/pinball_components/test/src/components/golden/plunger/pull.png index cdbb3e317d824070fff4c62b00fe92c05d7cfb87..3a3e204f8c322165f782e74dda12638de04cd0ab 100644 GIT binary patch literal 41522 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 CW=6yS literal 40669 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 U2xGxlJfMFO{qwG$UHQ#F0A7+n*#H0l diff --git a/packages/pinball_components/test/src/components/golden/plunger/release.png b/packages/pinball_components/test/src/components/golden/plunger/release.png index cda853c3c6d66eed86875f934362311dcb999d7a..2aae1a50736c77af0bc5c2069bfbb63d754ae03d 100644 GIT binary patch literal 40665 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 UrK$56NaZ0PM*Ok<4;QZf3szzzR{#J2 literal 41527 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 diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_jointing_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_jointing_behavior_test.dart new file mode 100644 index 00000000..940ea625 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_jointing_behavior_test.dart @@ -0,0 +1,36 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(Forge2DGame.new); + + group('PlungerJointingBehavior', () { + test('can be instantiated', () { + expect( + PlungerJointingBehavior(compressionDistance: 0), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final parent = Plunger.test(); + final behavior = PlungerJointingBehavior(compressionDistance: 0); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + expect(parent.children, contains(behavior)); + }); + + flameTester.test('creates a joint', (game) async { + final behavior = PlungerJointingBehavior(compressionDistance: 0); + final parent = Plunger.test(); + await game.ensureAdd(parent); + await parent.ensureAdd(behavior); + expect(parent.body.joints, isNotEmpty); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_key_controlling_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_key_controlling_behavior_test.dart new file mode 100644 index 00000000..1147d7f3 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_key_controlling_behavior_test.dart @@ -0,0 +1,194 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_components/pinball_components.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + PlungerKeyControllingBehavior child, { + PlungerCubit? plungerBloc, + }) async { + final plunger = Plunger.test(); + await ensureAdd(plunger); + return plunger.ensureAdd( + FlameBlocProvider.value( + value: plungerBloc ?? _MockPlungerCubit(), + children: [child], + ), + ); + } +} + +class _MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} + +class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + group('PlungerKeyControllingBehavior', () { + test('can be instantiated', () { + expect( + PlungerKeyControllingBehavior(), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + group('onKeyEvent', () { + late PlungerCubit plungerBloc; + + setUp(() { + plungerBloc = _MockPlungerCubit(); + }); + + group('pulls when', () { + flameTester.test( + 'down arrow is pressed', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyDownEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.arrowDown, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.pulled()).called(1); + }, + ); + + flameTester.test( + '"s" is pressed', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyDownEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.keyS, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.pulled()).called(1); + }, + ); + + flameTester.test( + 'space is pressed', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyDownEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.space, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.pulled()).called(1); + }, + ); + }); + + group('releases when', () { + flameTester.test( + 'down arrow is released', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.arrowDown, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.released()).called(1); + }, + ); + + flameTester.test( + '"s" is released', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.keyS, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.released()).called(1); + }, + ); + + flameTester.test( + 'space is released', + (game) async { + final behavior = PlungerKeyControllingBehavior(); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn( + LogicalKeyboardKey.space, + ); + + behavior.onKeyEvent(event, {}); + + verify(() => plungerBloc.released()).called(1); + }, + ); + }); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_noise_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_noise_behavior_test.dart new file mode 100644 index 00000000..a5e11ad0 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_noise_behavior_test.dart @@ -0,0 +1,91 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.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_audio/pinball_audio.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class _TestGame extends Forge2DGame { + Future pump( + Component child, { + PinballAudioPlayer? pinballAudioPlayer, + PlungerCubit? plungerBloc, + }) async { + final parent = Component(); + await ensureAdd(parent); + return parent.ensureAdd( + FlameProvider.value( + pinballAudioPlayer ?? _MockPinballAudioPlayer(), + children: [ + FlameBlocProvider.value( + value: plungerBloc ?? PlungerCubit(), + children: [child], + ), + ], + ), + ); + } +} + +class _MockPinballAudioPlayer extends Mock implements PinballAudioPlayer {} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + group('PlungerNoiseBehavior', () { + late PinballAudioPlayer audioPlayer; + + setUp(() { + audioPlayer = _MockPinballAudioPlayer(); + }); + + test('can be instantiated', () { + expect( + PlungerNoiseBehavior(), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerNoiseBehavior(); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test( + 'plays the correct sound when released', + (game) async { + final plungerBloc = _MockPlungerCubit(); + final streamController = StreamController(); + whenListen( + plungerBloc, + streamController.stream, + initialState: PlungerState.pulling, + ); + + final behavior = PlungerNoiseBehavior(); + await game.pump( + behavior, + pinballAudioPlayer: audioPlayer, + plungerBloc: plungerBloc, + ); + + streamController.add(PlungerState.releasing); + await Future.delayed(Duration.zero); + + verify(() => audioPlayer.play(PinballAudio.launcher)).called(1); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_pulling_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_pulling_behavior_test.dart new file mode 100644 index 00000000..4eec7029 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_pulling_behavior_test.dart @@ -0,0 +1,160 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_bloc/flame_bloc.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'; + +class _TestGame extends Forge2DGame { + Future pump( + PlungerPullingBehavior behavior, { + PlungerCubit? plungerBloc, + }) async { + final plunger = Plunger.test(); + await ensureAdd(plunger); + return plunger.ensureAdd( + FlameBlocProvider.value( + value: plungerBloc ?? _MockPlungerCubit(), + children: [behavior], + ), + ); + } +} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +class _MockPrismaticJoint extends Mock implements PrismaticJoint {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + group('PlungerPullingBehavior', () { + test('can be instantiated', () { + expect( + PlungerPullingBehavior(strength: 0), + isA(), + ); + }); + + test('throws assertion error when strength is negative ', () { + expect( + () => PlungerPullingBehavior(strength: -1), + throwsAssertionError, + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerPullingBehavior(strength: 0); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test( + 'applies vertical linear velocity when pulled', + (game) async { + final plungerBloc = _MockPlungerCubit(); + whenListen( + plungerBloc, + Stream.value(PlungerState.pulling), + initialState: PlungerState.pulling, + ); + + const strength = 2.0; + final behavior = PlungerPullingBehavior( + strength: strength, + ); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + game.update(0); + + final plunger = behavior.ancestors().whereType().single; + expect(plunger.body.linearVelocity.x, equals(0)); + expect(plunger.body.linearVelocity.y, equals(strength)); + }, + ); + }); + + group('PlungerAutoPullingBehavior', () { + test('can be instantiated', () { + expect( + PlungerAutoPullingBehavior(strength: 0), + isA(), + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerAutoPullingBehavior(strength: 0); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test( + "pulls while joint hasn't reached limit", + (game) async { + final plungerBloc = _MockPlungerCubit(); + whenListen( + plungerBloc, + Stream.value(PlungerState.pulling), + initialState: PlungerState.pulling, + ); + + const strength = 2.0; + final behavior = PlungerAutoPullingBehavior( + strength: strength, + ); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + final plunger = behavior.ancestors().whereType().single; + final joint = _MockPrismaticJoint(); + when(joint.getJointTranslation).thenReturn(2); + when(joint.getLowerLimit).thenReturn(0); + plunger.body.joints.add(joint); + + game.update(0); + + expect(plunger.body.linearVelocity.x, equals(0)); + expect(plunger.body.linearVelocity.y, equals(strength)); + }, + ); + + flameTester.test( + 'releases when joint reaches limit', + (game) async { + final plungerBloc = _MockPlungerCubit(); + whenListen( + plungerBloc, + Stream.value(PlungerState.pulling), + initialState: PlungerState.pulling, + ); + + const strength = 2.0; + final behavior = PlungerAutoPullingBehavior( + strength: strength, + ); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + final plunger = behavior.ancestors().whereType().single; + final joint = _MockPrismaticJoint(); + when(joint.getJointTranslation).thenReturn(0); + when(joint.getLowerLimit).thenReturn(0); + plunger.body.joints.add(joint); + + game.update(0); + + verify(plungerBloc.released).called(1); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/behaviors/plunger_releasing_behavior_test.dart b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_releasing_behavior_test.dart new file mode 100644 index 00000000..501753c4 --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/behaviors/plunger_releasing_behavior_test.dart @@ -0,0 +1,79 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/forge2d_game.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'; + +class _TestGame extends Forge2DGame { + Future pump( + PlungerReleasingBehavior behavior, { + PlungerCubit? plungerBloc, + }) async { + final plunger = Plunger.test(); + await ensureAdd(plunger); + return plunger.ensureAdd( + FlameBlocProvider.value( + value: plungerBloc ?? PlungerCubit(), + children: [behavior], + ), + ); + } +} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} + +void main() { + group('PlungerReleasingBehavior', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(_TestGame.new); + + test('can be instantiated', () { + expect( + PlungerReleasingBehavior(strength: 0), + isA(), + ); + }); + + test('throws assertion error when strength is negative ', () { + expect( + () => PlungerReleasingBehavior(strength: -1), + throwsAssertionError, + ); + }); + + flameTester.test('can be loaded', (game) async { + final behavior = PlungerReleasingBehavior(strength: 0); + await game.pump(behavior); + expect(game.descendants(), contains(behavior)); + }); + + flameTester.test('applies vertical linear velocity', (game) async { + final plungerBloc = _MockPlungerCubit(); + final streamController = StreamController(); + whenListen( + plungerBloc, + streamController.stream, + initialState: PlungerState.pulling, + ); + + final behavior = PlungerReleasingBehavior(strength: 2); + await game.pump( + behavior, + plungerBloc: plungerBloc, + ); + + streamController.add(PlungerState.releasing); + await Future.delayed(Duration.zero); + + final plunger = behavior.ancestors().whereType().single; + expect(plunger.body.linearVelocity.x, equals(0)); + expect(plunger.body.linearVelocity.y, isNot(greaterThan(0))); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger/plunger_test.dart b/packages/pinball_components/test/src/components/plunger/plunger_test.dart new file mode 100644 index 00000000..32a6a45b --- /dev/null +++ b/packages/pinball_components/test/src/components/plunger/plunger_test.dart @@ -0,0 +1,116 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(TestGame.new); + + group('Plunger', () { + test('can be instantiated', () { + expect(Plunger(), isA()); + }); + + flameTester.test( + 'loads correctly', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect(game.children, contains(plunger)); + }, + ); + + group('adds', () { + flameTester.test( + 'a PlungerReleasingBehavior', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'a PlungerJointingBehavior', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'a PlungerNoiseBehavior', + (game) async { + final plunger = Plunger(); + await game.ensureAdd(plunger); + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + }); + + group('renders correctly', () { + const goldenPath = '../golden/plunger/'; + flameTester.testGameWidget( + 'pulling', + setUp: (game, tester) async { + await game.ensureAdd(Plunger()); + game.camera.followVector2(Vector2.zero()); + game.camera.zoom = 4.1; + }, + verify: (game, tester) async { + final plunger = game.descendants().whereType().first; + final bloc = plunger + .descendants() + .whereType>() + .single + .bloc; + bloc.pulled(); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('${goldenPath}pull.png'), + ); + }, + ); + + flameTester.testGameWidget( + 'releasing', + setUp: (game, tester) async { + await game.ensureAdd(Plunger()); + game.camera.followVector2(Vector2.zero()); + game.camera.zoom = 4.1; + }, + verify: (game, tester) async { + final plunger = game.descendants().whereType().first; + final bloc = plunger + .descendants() + .whereType>() + .single + .bloc; + bloc.released(); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('${goldenPath}release.png'), + ); + }, + ); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/plunger_test.dart b/packages/pinball_components/test/src/components/plunger_test.dart deleted file mode 100644 index e28bdaed..00000000 --- a/packages/pinball_components/test/src/components/plunger_test.dart +++ /dev/null @@ -1,391 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball_components/pinball_components.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(TestGame.new); - - group('Plunger', () { - const compressionDistance = 0.0; - - test('can be instantiated', () { - expect( - Plunger(compressionDistance: compressionDistance), - isA(), - ); - expect( - Plunger.test(compressionDistance: compressionDistance), - isA(), - ); - }); - - flameTester.testGameWidget( - 'renders correctly', - setUp: (game, tester) async { - await game.ensureAdd(Plunger(compressionDistance: compressionDistance)); - - game.camera.followVector2(Vector2.zero()); - game.camera.zoom = 4.1; - }, - verify: (game, tester) async { - final plunger = game.descendants().whereType().first; - plunger.pull(); - game.update(1); - await tester.pump(); - await expectLater( - find.byGame(), - matchesGoldenFile('golden/plunger/pull.png'), - ); - - plunger.release(); - game.update(1); - await tester.pump(); - await expectLater( - find.byGame(), - matchesGoldenFile('golden/plunger/release.png'), - ); - }, - ); - - flameTester.test( - 'loads correctly', - (game) async { - await game.ready(); - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(game.contains(plunger), isTrue); - }, - ); - - group('body', () { - flameTester.test( - 'is dynamic', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(plunger.body.bodyType, equals(BodyType.dynamic)); - }, - ); - - flameTester.test( - 'ignores gravity', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(plunger.body.gravityScale, equals(Vector2.zero())); - }, - ); - }); - - group('fixture', () { - flameTester.test( - 'exists', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - expect(plunger.body.fixtures[0], isA()); - }, - ); - - flameTester.test( - 'shape is a polygon', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - final fixture = plunger.body.fixtures[0]; - expect(fixture.shape.shapeType, equals(ShapeType.polygon)); - }, - ); - - flameTester.test( - 'has density', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - final fixture = plunger.body.fixtures[0]; - expect(fixture.density, greaterThan(0)); - }, - ); - }); - - group('pullFor', () { - late Plunger plunger; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - }); - - flameTester.testGameWidget( - 'moves downwards for given period when pullFor is called', - setUp: (game, tester) async { - await game.ensureAdd(plunger); - }, - verify: (game, tester) async { - plunger.pullFor(2); - game.update(0); - - expect(plunger.body.linearVelocity.y, isPositive); - - // Call game update at 120 FPS, so that the plunger will act as if it - // was pulled for 2 seconds. - for (var i = 0.0; i < 2; i += 1 / 120) { - game.update(1 / 20); - } - - expect(plunger.body.linearVelocity.y, isZero); - }, - ); - }); - - group('pull', () { - late Plunger plunger; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - }); - - flameTester.test( - 'moves downwards when pull is called', - (game) async { - await game.ensureAdd(plunger); - plunger.pull(); - - expect(plunger.body.linearVelocity.y, isPositive); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - - flameTester.test( - 'moves downwards when pull is called ' - 'and plunger is below its starting position', (game) async { - await game.ensureAdd(plunger); - plunger.pull(); - plunger.release(); - plunger.pull(); - - expect(plunger.body.linearVelocity.y, isPositive); - expect(plunger.body.linearVelocity.x, isZero); - }); - }); - - group('release', () { - late Plunger plunger; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - }); - - flameTester.test( - 'moves upwards when release is called ' - 'and plunger is below its starting position', (game) async { - await game.ensureAdd(plunger); - plunger.body.setTransform(Vector2(0, 1), 0); - plunger.release(); - - expect(plunger.body.linearVelocity.y, isNegative); - expect(plunger.body.linearVelocity.x, isZero); - }); - - flameTester.test( - 'does not move when release is called ' - 'and plunger is in its starting position', - (game) async { - await game.ensureAdd(plunger); - plunger.release(); - - expect(plunger.body.linearVelocity.y, isZero); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - }); - - group('PlungerAnchor', () { - const compressionDistance = 10.0; - - flameTester.test( - 'position is a compression distance below the Plunger', - (game) async { - final plunger = Plunger( - compressionDistance: compressionDistance, - ); - await game.ensureAdd(plunger); - - final plungerAnchor = PlungerAnchor(plunger: plunger); - await game.ensureAdd(plungerAnchor); - - expect( - plungerAnchor.body.position.y, - equals(plunger.body.position.y + compressionDistance), - ); - }, - ); - }); - - group('PlungerAnchorPrismaticJointDef', () { - const compressionDistance = 10.0; - late Plunger plunger; - late PlungerAnchor anchor; - - setUp(() { - plunger = Plunger( - compressionDistance: compressionDistance, - ); - anchor = PlungerAnchor(plunger: plunger); - }); - - group('initializes with', () { - flameTester.test( - 'plunger body as bodyA', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - - expect(jointDef.bodyA, equals(plunger.body)); - }, - ); - - flameTester.test( - 'anchor body as bodyB', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.bodyB, equals(anchor.body)); - }, - ); - - flameTester.test( - 'limits enabled', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.enableLimit, isTrue); - }, - ); - - flameTester.test( - 'lower translation limit as negative infinity', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.lowerTranslation, equals(double.negativeInfinity)); - }, - ); - - flameTester.test( - 'connected body collision enabled', - (game) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - expect(jointDef.collideConnected, isTrue); - }, - ); - }); - - flameTester.testGameWidget( - 'plunger cannot go below anchor', - setUp: (game, tester) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - // Giving anchor a shape for the plunger to collide with. - anchor.body.createFixtureFromShape(PolygonShape()..setAsBoxXY(2, 1)); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - await tester.pump(const Duration(seconds: 1)); - }, - verify: (game, tester) async { - expect(plunger.body.position.y < anchor.body.position.y, isTrue); - }, - ); - - flameTester.testGameWidget( - 'plunger cannot excessively exceed starting position', - setUp: (game, tester) async { - await game.ensureAdd(plunger); - await game.ensureAdd(anchor); - - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(PrismaticJoint(jointDef)); - - plunger.body.setTransform(Vector2(0, -1), 0); - - await tester.pump(const Duration(seconds: 1)); - }, - verify: (game, tester) async { - expect(plunger.body.position.y < 1, isTrue); - }, - ); - }); -} diff --git a/packages/pinball_flame/lib/pinball_flame.dart b/packages/pinball_flame/lib/pinball_flame.dart index c40405cb..410d3151 100644 --- a/packages/pinball_flame/lib/pinball_flame.dart +++ b/packages/pinball_flame/lib/pinball_flame.dart @@ -2,7 +2,6 @@ library pinball_flame; export 'src/behaviors/behaviors.dart'; export 'src/canvas/canvas.dart'; -export 'src/component_controller.dart'; export 'src/flame_provider.dart'; export 'src/keyboard_input_controller.dart'; export 'src/layer.dart'; diff --git a/packages/pinball_flame/lib/src/component_controller.dart b/packages/pinball_flame/lib/src/component_controller.dart deleted file mode 100644 index 6afc1c40..00000000 --- a/packages/pinball_flame/lib/src/component_controller.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flutter/foundation.dart'; - -/// {@template component_controller} -/// A [ComponentController] is a [Component] in charge of handling the logic -/// associated with another [Component]. -/// {@endtemplate} -abstract class ComponentController extends Component { - /// {@macro component_controller} - ComponentController(this.component); - - /// The [Component] controlled by this [ComponentController]. - final T component; - - @override - Future addToParent(Component parent) async { - assert( - parent == component, - 'ComponentController should be child of $component.', - ); - await super.addToParent(parent); - } - - @override - Future add(Component component) { - throw Exception('ComponentController cannot add other components.'); - } -} - -/// Mixin that attaches a single [ComponentController] to a [Component]. -mixin Controls on Component { - /// The [ComponentController] attached to this [Component]. - late T controller; - - @override - @mustCallSuper - Future onLoad() async { - await super.onLoad(); - await add(controller); - } -} diff --git a/packages/pinball_flame/test/src/component_controller_test.dart b/packages/pinball_flame/test/src/component_controller_test.dart deleted file mode 100644 index addcf2b0..00000000 --- a/packages/pinball_flame/test/src/component_controller_test.dart +++ /dev/null @@ -1,96 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame/game.dart'; -import 'package:flame/src/components/component.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -class TestComponentController extends ComponentController { - TestComponentController(Component component) : super(component); -} - -class ControlledComponent extends Component - with Controls { - ControlledComponent() : super() { - controller = TestComponentController(this); - } -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(FlameGame.new); - - group('ComponentController', () { - flameTester.test( - 'can be instantiated', - (game) async { - expect( - TestComponentController(Component()), - isA(), - ); - }, - ); - - flameTester.test( - 'throws AssertionError when not attached to controlled component', - (game) async { - final component = Component(); - final controller = TestComponentController(component); - - final anotherComponent = Component(); - await expectLater( - () async => await anotherComponent.add(controller), - throwsAssertionError, - ); - }, - ); - - flameTester.test( - 'throws Exception when adding a component', - (game) async { - final component = ControlledComponent(); - final controller = TestComponentController(component); - - await expectLater( - () async => controller.add(Component()), - throwsException, - ); - }, - ); - - flameTester.test( - 'throws Exception when adding multiple components', - (game) async { - final component = ControlledComponent(); - final controller = TestComponentController(component); - - await expectLater( - () async => controller.addAll([ - Component(), - Component(), - ]), - throwsException, - ); - }, - ); - }); - - group('Controls', () { - flameTester.test( - 'can be instantiated', - (game) async { - expect(ControlledComponent(), isA()); - }, - ); - - flameTester.test('adds controller', (game) async { - final component = ControlledComponent(); - - await game.add(component); - await game.ready(); - - expect(component.contains(component.controller), isTrue); - }); - }); -} diff --git a/test/game/behaviors/ball_spawning_behavior_test.dart b/test/game/behaviors/ball_spawning_behavior_test.dart index d723c65e..dc272571 100644 --- a/test/game/behaviors/ball_spawning_behavior_test.dart +++ b/test/game/behaviors/ball_spawning_behavior_test.dart @@ -130,7 +130,7 @@ void main() { await game.pump([ behavior, ZCanvasComponent(), - Plunger.test(compressionDistance: 10), + Plunger.test(), ]); expect(game.descendants().whereType(), isEmpty); diff --git a/test/game/behaviors/character_selection_behavior_test.dart b/test/game/behaviors/character_selection_behavior_test.dart index 5bcd6c50..acf140a2 100644 --- a/test/game/behaviors/character_selection_behavior_test.dart +++ b/test/game/behaviors/character_selection_behavior_test.dart @@ -79,8 +79,6 @@ void main() { flameTester.test( 'onNewState calls onCharacterSelected on the arcade background bloc', (game) async { - final platformHelper = _MockPlatformHelper(); - when(() => platformHelper.isMobile).thenAnswer((_) => false); final arcadeBackgroundBloc = _MockArcadeBackgroundCubit(); whenListen( arcadeBackgroundBloc, @@ -95,10 +93,9 @@ void main() { arcadeBackground, behavior, ZCanvasComponent(), - Plunger.test(compressionDistance: 10), + Plunger.test(), Ball.test(), ], - platformHelper: platformHelper, ); const dinoThemeState = CharacterThemeState(theme.DinoTheme()); @@ -130,7 +127,7 @@ void main() { ball, behavior, ZCanvasComponent(), - Plunger.test(compressionDistance: 10), + Plunger.test(), ArcadeBackground.test(), ], platformHelper: platformHelper, diff --git a/test/game/components/controlled_plunger_test.dart b/test/game/components/controlled_plunger_test.dart deleted file mode 100644 index 68bde767..00000000 --- a/test/game/components/controlled_plunger_test.dart +++ /dev/null @@ -1,185 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'dart:collection'; - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flame/components.dart'; -import 'package:flame/input.dart'; -import 'package:flame_bloc/flame_bloc.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_audio/pinball_audio.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -import '../../helpers/helpers.dart'; - -class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents { - @override - Future onLoad() async { - images.prefix = ''; - await images.load(Assets.images.plunger.plunger.keyName); - } - - Future pump( - Plunger child, { - GameBloc? gameBloc, - PinballAudioPlayer? pinballAudioPlayer, - }) { - return ensureAdd( - FlameBlocProvider.value( - value: gameBloc ?? GameBloc() - ..add(const GameStarted()), - children: [ - FlameProvider.value( - pinballAudioPlayer ?? _MockPinballAudioPlayer(), - children: [child], - ) - ], - ), - ); - } -} - -class _MockGameBloc extends Mock implements GameBloc {} - -class _MockPinballAudioPlayer extends Mock implements PinballAudioPlayer {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(_TestGame.new); - - group('PlungerController', () { - late GameBloc gameBloc; - - final flameBlocTester = FlameTester(_TestGame.new); - - late Plunger plunger; - late PlungerController controller; - - setUp(() { - gameBloc = _MockGameBloc(); - plunger = ControlledPlunger(compressionDistance: 10); - controller = PlungerController(plunger); - plunger.add(controller); - }); - - group('onKeyEvent', () { - final downKeys = UnmodifiableListView([ - LogicalKeyboardKey.arrowDown, - LogicalKeyboardKey.space, - LogicalKeyboardKey.keyS, - ]); - - testRawKeyDownEvents(downKeys, (event) { - flameTester.test( - 'moves down ' - 'when ${event.logicalKey.keyLabel} is pressed', - (game) async { - await game.pump(plunger); - controller.onKeyEvent(event, {}); - - expect(plunger.body.linearVelocity.y, isPositive); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - - testRawKeyUpEvents(downKeys, (event) { - flameTester.test( - 'moves up ' - 'when ${event.logicalKey.keyLabel} is released ' - 'and plunger is below its starting position', - (game) async { - await game.pump(plunger); - plunger.body.setTransform(Vector2(0, 1), 0); - controller.onKeyEvent(event, {}); - - expect(plunger.body.linearVelocity.y, isNegative); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - - testRawKeyUpEvents(downKeys, (event) { - flameTester.test( - 'does not move when ${event.logicalKey.keyLabel} is released ' - 'and plunger is in its starting position', - (game) async { - await game.pump(plunger); - controller.onKeyEvent(event, {}); - - expect(plunger.body.linearVelocity.y, isZero); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - - testRawKeyDownEvents(downKeys, (event) { - flameBlocTester.testGameWidget( - 'does nothing when is game over', - setUp: (game, tester) async { - whenListen( - gameBloc, - const Stream.empty(), - initialState: const GameState.initial().copyWith( - status: GameStatus.gameOver, - ), - ); - - await game.pump(plunger, gameBloc: gameBloc); - controller.onKeyEvent(event, {}); - }, - verify: (game, tester) async { - expect(plunger.body.linearVelocity.y, isZero); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - }); - }); - - flameTester.test( - 'adds the PlungerNoiseBehavior plunger is released', - (game) async { - await game.pump(plunger); - plunger.body.setTransform(Vector2(0, 1), 0); - plunger.release(); - - await game.ready(); - final count = - game.descendants().whereType().length; - expect(count, equals(1)); - }, - ); - }); - - group('PlungerNoiseBehavior', () { - late PinballAudioPlayer audioPlayer; - - setUp(() { - audioPlayer = _MockPinballAudioPlayer(); - }); - - flameTester.test('plays the correct sound on load', (game) async { - final parent = ControlledPlunger(compressionDistance: 10); - await game.pump(parent, pinballAudioPlayer: audioPlayer); - await parent.ensureAdd(PlungerNoiseBehavior()); - verify(() => audioPlayer.play(PinballAudio.launcher)).called(1); - }); - - test('is removed on the first update', () { - final parent = Component(); - final behavior = PlungerNoiseBehavior(); - parent.add(behavior); - parent.update(0); // Run a tick to ensure it is added - - behavior.update(0); // Run its own update where the removal happens - - expect(behavior.shouldRemove, isTrue); - }); - }); -} diff --git a/test/game/components/game_bloc_status_listener_test.dart b/test/game/components/game_bloc_status_listener_test.dart index d468ce2f..3519dbbd 100644 --- a/test/game/components/game_bloc_status_listener_test.dart +++ b/test/game/components/game_bloc_status_listener_test.dart @@ -36,6 +36,7 @@ class _TestGame extends Forge2DGame with HasTappables { Future pump( Iterable children, { PinballAudioPlayer? pinballAudioPlayer, + PlatformHelper? platformHelper, }) async { return ensureAdd( FlameMultiBlocProvider( @@ -57,7 +58,7 @@ class _TestGame extends Forge2DGame with HasTappables { _MockAppLocalizations(), ), FlameProvider.value( - _MockPlatformHelper(), + platformHelper ?? PlatformHelper(), ), ], children: children, @@ -75,10 +76,9 @@ class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { class _MockShareRepository extends Mock implements ShareRepository {} -class _MockPlatformHelper extends Mock implements PlatformHelper { - @override - bool get isMobile => false; -} +class _MockPlatformHelper extends Mock implements PlatformHelper {} + +class _MockPlungerCubit extends Mock implements PlungerCubit {} class _MockAppLocalizations extends Mock implements AppLocalizations { @override @@ -196,7 +196,6 @@ void main() { await flipper.ensureAdd(behavior); expect(state.status, GameStatus.gameOver); - component.onNewState(state); await game.ready(); @@ -207,6 +206,77 @@ void main() { }, ); + flameTester.test( + 'removes PlungerKeyControllingBehavior from Plunger', + (game) async { + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + ); + + await plunger.ensureAdd( + FlameBlocProvider( + create: PlungerCubit.new, + children: [PlungerKeyControllingBehavior()], + ), + ); + + expect(state.status, GameStatus.gameOver); + component.onNewState(state); + await game.ready(); + + expect( + plunger.children.whereType(), + isEmpty, + ); + }, + ); + + flameTester.test( + 'removes PlungerPullingBehavior from Plunger', + (game) async { + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + ); + + await plunger.ensureAdd( + FlameBlocProvider( + create: PlungerCubit.new, + children: [ + PlungerPullingBehavior(strength: 0), + PlungerAutoPullingBehavior(strength: 0) + ], + ), + ); + + expect(state.status, GameStatus.gameOver); + component.onNewState(state); + await game.ready(); + + expect( + plunger.children.whereType(), + isEmpty, + ); + }, + ); + flameTester.test( 'plays the game over voice over', (game) async { @@ -263,7 +333,7 @@ void main() { ); flameTester.test( - 'adds key controlling behavior to Flippers when the game is started', + 'adds FlipperKeyControllingBehavior to Flippers', (game) async { final component = GameBlocStatusListener(); final leaderboardRepository = _MockLeaderboardRepository(); @@ -288,6 +358,120 @@ void main() { ); }, ); + + flameTester.test( + 'adds PlungerKeyControllingBehavior to Plunger when on desktop', + (game) async { + final platformHelper = _MockPlatformHelper(); + when(() => platformHelper.isMobile).thenReturn(false); + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + platformHelper: platformHelper, + ); + await plunger.ensureAdd( + FlameBlocProvider( + create: _MockPlungerCubit.new, + ), + ); + + expect(state.status, GameStatus.playing); + + component.onNewState(state); + await game.ready(); + + expect( + plunger + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds PlungerPullingBehavior to Plunger when on desktop', + (game) async { + final platformHelper = _MockPlatformHelper(); + when(() => platformHelper.isMobile).thenReturn(false); + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + platformHelper: platformHelper, + ); + await plunger.ensureAdd( + FlameBlocProvider( + create: _MockPlungerCubit.new, + ), + ); + + expect(state.status, GameStatus.playing); + + component.onNewState(state); + await game.ready(); + + expect( + plunger.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds PlungerAutoPullingBehavior to Plunger when on mobile', + (game) async { + final platformHelper = _MockPlatformHelper(); + when(() => platformHelper.isMobile).thenReturn(true); + final component = GameBlocStatusListener(); + final leaderboardRepository = _MockLeaderboardRepository(); + final shareRepository = _MockShareRepository(); + final backbox = Backbox( + leaderboardRepository: leaderboardRepository, + shareRepository: shareRepository, + entries: const [], + ); + final plunger = Plunger.test(); + await game.pump( + [component, backbox, plunger], + platformHelper: platformHelper, + ); + await plunger.ensureAdd( + FlameBlocProvider( + create: _MockPlungerCubit.new, + ), + ); + + expect(state.status, GameStatus.playing); + + component.onNewState(state); + await game.ready(); + + expect( + plunger + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); }); }); }); diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 289fb4fa..a95f329b 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame/input.dart'; +import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -395,7 +396,7 @@ void main() { }); group('plunger control', () { - flameTester.test('tap down moves plunger down', (game) async { + flameTester.test('tap down emits plunging', (game) async { await game.ready(); final eventPosition = _MockEventPosition(); @@ -408,13 +409,15 @@ void main() { when(() => tapDownEvent.eventPosition).thenReturn(eventPosition); when(() => tapDownEvent.raw).thenReturn(raw); - final plunger = game.descendants().whereType().first; - game.onTapDown(0, tapDownEvent); - game.update(1); + final plungerBloc = game + .descendants() + .whereType>() + .single + .bloc; - expect(plunger.body.linearVelocity.y, isPositive); + expect(plungerBloc.state, PlungerState.pulling); }); }); }); diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 613fd5b8..2f16567a 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -1,3 +1,2 @@ -export 'key_testers.dart'; export 'mock_flame_images.dart'; export 'pump_app.dart'; diff --git a/test/helpers/key_testers.dart b/test/helpers/key_testers.dart deleted file mode 100644 index ff870d6c..00000000 --- a/test/helpers/key_testers.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:meta/meta.dart'; -import 'package:mocktail/mocktail.dart'; - -class _MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { - @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return super.toString(); - } -} - -class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { - @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return super.toString(); - } -} - -@isTest -void testRawKeyUpEvents( - List keys, - Function(RawKeyUpEvent) test, -) { - for (final key in keys) { - test(_mockKeyUpEvent(key)); - } -} - -RawKeyUpEvent _mockKeyUpEvent(LogicalKeyboardKey key) { - final event = _MockRawKeyUpEvent(); - when(() => event.logicalKey).thenReturn(key); - return event; -} - -@isTest -void testRawKeyDownEvents( - List keys, - Function(RawKeyDownEvent) test, -) { - for (final key in keys) { - test(_mockKeyDownEvent(key)); - } -} - -RawKeyDownEvent _mockKeyDownEvent(LogicalKeyboardKey key) { - final event = _MockRawKeyDownEvent(); - when(() => event.logicalKey).thenReturn(key); - return event; -} From 43ceb0db327f5266e96834d01aab96ef34dff404 Mon Sep 17 00:00:00 2001 From: Jorge Coca Date: Mon, 9 May 2022 11:02:29 -0500 Subject: [PATCH 3/7] fix: await firebase init and anonymous auth (#439) --- lib/main.dart | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 877843ee..11bf35aa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:authentication_repository/authentication_repository.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; @@ -17,11 +15,8 @@ void main() { final authenticationRepository = AuthenticationRepository(firebaseAuth); final pinballAudioPlayer = PinballAudioPlayer(); final platformHelper = PlatformHelper(); - unawaited( - Firebase.initializeApp().then( - (_) => authenticationRepository.authenticateAnonymously(), - ), - ); + await Firebase.initializeApp(); + await authenticationRepository.authenticateAnonymously(); return App( authenticationRepository: authenticationRepository, leaderboardRepository: leaderboardRepository, From 03c472837274afe1f13a2c248d63d754fa52fb91 Mon Sep 17 00:00:00 2001 From: Jorge Coca Date: Mon, 9 May 2022 11:52:36 -0500 Subject: [PATCH 4/7] fix: open source link not opening on mobile (#440) --- .../components/backbox/displays/game_over_info_display.dart | 2 +- .../backbox/displays/game_over_info_display_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/game/components/backbox/displays/game_over_info_display.dart b/lib/game/components/backbox/displays/game_over_info_display.dart index 52939345..0ac160ee 100644 --- a/lib/game/components/backbox/displays/game_over_info_display.dart +++ b/lib/game/components/backbox/displays/game_over_info_display.dart @@ -290,7 +290,7 @@ class OpenSourceTextComponent extends TextComponent with HasGameRef, Tappable { ); @override - bool onTapDown(TapDownInfo info) { + bool onTapUp(TapUpInfo info) { openLink(ShareRepository.openSourceCode); return true; } diff --git a/test/game/components/backbox/displays/game_over_info_display_test.dart b/test/game/components/backbox/displays/game_over_info_display_test.dart index 2bee4005..bb092347 100644 --- a/test/game/components/backbox/displays/game_over_info_display_test.dart +++ b/test/game/components/backbox/displays/game_over_info_display_test.dart @@ -176,7 +176,7 @@ void main() { final openSourceLink = component.descendants().whereType().first; - openSourceLink.onTapDown(_MockTapDownInfo()); + openSourceLink.onTapUp(_MockTapUpInfo()); await game.ready(); From 0d52fcd72a2c3100563c0065376c6166d8d858e2 Mon Sep 17 00:00:00 2001 From: Elizabeth Gaston Date: Mon, 9 May 2022 12:36:59 -0500 Subject: [PATCH 5/7] chore: Update text for sharing to social media (#429) * chore: Update text for sharing to social media * Update app_en.arb * Adding line break Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> --- lib/l10n/arb/app_en.arb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 64093ac6..6b6e55aa 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -213,7 +213,7 @@ "@socialMediaAccount": { "description": "Text displayed on share screen for description" }, - "iGotScoreAtPinball": "I got {score} at the #IOPinball machine, can you beat my score? See you at #GoogleIO!", + "iGotScoreAtPinball": "I got {score} points in #IOPinball, can you beat my score? \nSee you at #GoogleIO!", "@iGotScoreAtPinball": { "description": "Text to share score on Social Network", "placeholders": { From 032618020e7c9ef691d0c366dcd3a41a6cf52494 Mon Sep 17 00:00:00 2001 From: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Date: Mon, 9 May 2022 12:57:16 -0500 Subject: [PATCH 6/7] fix: android bonus animation (#442) --- .../android_animatronic.dart | 10 ++++++ ...imatronic_ball_contact_behavior.dart.dart} | 9 ++--- .../behaviors/behaviors.dart | 1 + .../android_spaceship/android_spaceship.dart | 33 ------------------- .../behaviors/behaviors.dart | 1 - .../cubit/android_spaceship_cubit.dart | 2 +- .../lib/src/components/components.dart | 2 +- ...imatronic_ball_contact_behavior_test.dart} | 20 +++++------ .../components/android_animatronic_test.dart | 28 ++++++++++++---- .../android_spaceship_test.dart | 22 ------------- .../cubit/android_spaceship_cubit_test.dart | 2 +- 11 files changed, 48 insertions(+), 82 deletions(-) rename packages/pinball_components/lib/src/components/{ => android_animatronic}/android_animatronic.dart (83%) rename packages/pinball_components/lib/src/components/{android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior.dart.dart => android_animatronic/behaviors/android_animatronic_ball_contact_behavior.dart.dart} (58%) create mode 100644 packages/pinball_components/lib/src/components/android_animatronic/behaviors/behaviors.dart delete mode 100644 packages/pinball_components/lib/src/components/android_spaceship/behaviors/behaviors.dart rename packages/pinball_components/test/src/components/{android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior_test.dart => android_animatronic/behaviors/android_animatronic_ball_contact_behavior_test.dart} (69%) diff --git a/packages/pinball_components/lib/src/components/android_animatronic.dart b/packages/pinball_components/lib/src/components/android_animatronic/android_animatronic.dart similarity index 83% rename from packages/pinball_components/lib/src/components/android_animatronic.dart rename to packages/pinball_components/lib/src/components/android_animatronic/android_animatronic.dart index 772d88c4..c78b387c 100644 --- a/packages/pinball_components/lib/src/components/android_animatronic.dart +++ b/packages/pinball_components/lib/src/components/android_animatronic/android_animatronic.dart @@ -1,6 +1,8 @@ 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/android_animatronic/behaviors/behaviors.dart'; import 'package:pinball_flame/pinball_flame.dart'; /// {@template android_animatronic} @@ -13,6 +15,7 @@ class AndroidAnimatronic extends BodyComponent : super( children: [ _AndroidAnimatronicSpriteAnimationComponent(), + AndroidAnimatronicBallContactBehavior(), ...?children, ], renderBody: false, @@ -21,6 +24,13 @@ class AndroidAnimatronic extends BodyComponent zIndex = ZIndexes.androidHead; } + /// Creates an [AndroidAnimatronic] without any children. + /// + /// This can be used for testing [AndroidAnimatronic]'s behaviors in + /// isolation. + @visibleForTesting + AndroidAnimatronic.test(); + @override Body createBody() { final shape = EllipseShape( diff --git a/packages/pinball_components/lib/src/components/android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior.dart.dart b/packages/pinball_components/lib/src/components/android_animatronic/behaviors/android_animatronic_ball_contact_behavior.dart.dart similarity index 58% rename from packages/pinball_components/lib/src/components/android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior.dart.dart rename to packages/pinball_components/lib/src/components/android_animatronic/behaviors/android_animatronic_ball_contact_behavior.dart.dart index b577b7b3..6c74e21a 100644 --- a/packages/pinball_components/lib/src/components/android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior.dart.dart +++ b/packages/pinball_components/lib/src/components/android_animatronic/behaviors/android_animatronic_ball_contact_behavior.dart.dart @@ -1,18 +1,15 @@ // ignore_for_file: public_member_api_docs -import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; -class AndroidSpaceshipEntranceBallContactBehavior - extends ContactBehavior - with FlameBlocReader { +class AndroidAnimatronicBallContactBehavior + extends ContactBehavior { @override void beginContact(Object other, Contact contact) { super.beginContact(other, contact); if (other is! Ball) return; - - bloc.onBallEntered(); + readBloc().onBallContacted(); } } diff --git a/packages/pinball_components/lib/src/components/android_animatronic/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/android_animatronic/behaviors/behaviors.dart new file mode 100644 index 00000000..e85e749f --- /dev/null +++ b/packages/pinball_components/lib/src/components/android_animatronic/behaviors/behaviors.dart @@ -0,0 +1 @@ +export 'android_animatronic_ball_contact_behavior.dart.dart'; diff --git a/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart b/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart index 0fd4628d..d09ff1e4 100644 --- a/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart +++ b/packages/pinball_components/lib/src/components/android_spaceship/android_spaceship.dart @@ -5,7 +5,6 @@ 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/android_spaceship/behaviors/behaviors.dart'; import 'package:pinball_flame/pinball_flame.dart'; export 'cubit/android_spaceship_cubit.dart'; @@ -17,9 +16,6 @@ class AndroidSpaceship extends Component { _SpaceshipSaucer()..initialPosition = position, _SpaceshipSaucerSpriteAnimationComponent()..position = position, _LightBeamSpriteComponent()..position = position + Vector2(2.5, 5), - AndroidSpaceshipEntrance( - children: [AndroidSpaceshipEntranceBallContactBehavior()], - ), _SpaceshipHole( outsideLayer: Layer.spaceshipExitRail, outsidePriority: ZIndexes.ballOnSpaceshipRail, @@ -134,35 +130,6 @@ class _LightBeamSpriteComponent extends SpriteComponent } } -class AndroidSpaceshipEntrance extends BodyComponent - with ParentIsA, Layered { - AndroidSpaceshipEntrance({Iterable? children}) - : super( - children: children, - renderBody: false, - ) { - layer = Layer.spaceship; - } - - @override - Body createBody() { - final shape = PolygonShape() - ..setAsBox( - 2, - 0.1, - Vector2(-27.4, -37.2), - -0.12, - ); - final fixtureDef = FixtureDef( - shape, - isSensor: true, - ); - final bodyDef = BodyDef(); - - return world.createBody(bodyDef)..createFixture(fixtureDef); - } -} - class _SpaceshipHole extends LayerSensor { _SpaceshipHole({required Layer outsideLayer, required int outsidePriority}) : super( diff --git a/packages/pinball_components/lib/src/components/android_spaceship/behaviors/behaviors.dart b/packages/pinball_components/lib/src/components/android_spaceship/behaviors/behaviors.dart deleted file mode 100644 index cbf54e5d..00000000 --- a/packages/pinball_components/lib/src/components/android_spaceship/behaviors/behaviors.dart +++ /dev/null @@ -1 +0,0 @@ -export 'android_spaceship_entrance_ball_contact_behavior.dart.dart'; diff --git a/packages/pinball_components/lib/src/components/android_spaceship/cubit/android_spaceship_cubit.dart b/packages/pinball_components/lib/src/components/android_spaceship/cubit/android_spaceship_cubit.dart index 334c9cc3..5057d742 100644 --- a/packages/pinball_components/lib/src/components/android_spaceship/cubit/android_spaceship_cubit.dart +++ b/packages/pinball_components/lib/src/components/android_spaceship/cubit/android_spaceship_cubit.dart @@ -5,7 +5,7 @@ part 'android_spaceship_state.dart'; class AndroidSpaceshipCubit extends Cubit { AndroidSpaceshipCubit() : super(AndroidSpaceshipState.withoutBonus); - void onBallEntered() => emit(AndroidSpaceshipState.withBonus); + void onBallContacted() => emit(AndroidSpaceshipState.withBonus); void onBonusAwarded() => emit(AndroidSpaceshipState.withoutBonus); } diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 8fd74268..63684921 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -1,4 +1,4 @@ -export 'android_animatronic.dart'; +export 'android_animatronic/android_animatronic.dart'; export 'android_bumper/android_bumper.dart'; export 'android_spaceship/android_spaceship.dart'; export 'arcade_background/arcade_background.dart'; diff --git a/packages/pinball_components/test/src/components/android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior_test.dart b/packages/pinball_components/test/src/components/android_animatronic/behaviors/android_animatronic_ball_contact_behavior_test.dart similarity index 69% rename from packages/pinball_components/test/src/components/android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior_test.dart rename to packages/pinball_components/test/src/components/android_animatronic/behaviors/android_animatronic_ball_contact_behavior_test.dart index 4b0f16ea..4d8bb675 100644 --- a/packages/pinball_components/test/src/components/android_spaceship/behaviors/android_spaceship_entrance_ball_contact_behavior_test.dart +++ b/packages/pinball_components/test/src/components/android_animatronic/behaviors/android_animatronic_ball_contact_behavior_test.dart @@ -7,7 +7,7 @@ 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/android_spaceship/behaviors/behaviors.dart'; +import 'package:pinball_components/src/components/android_animatronic/behaviors/behaviors.dart'; import '../../../../helpers/helpers.dart'; @@ -23,19 +23,19 @@ void main() { final flameTester = FlameTester(TestGame.new); group( - 'AndroidSpaceshipEntranceBallContactBehavior', + 'AndroidAnimatronicBallContactBehavior', () { test('can be instantiated', () { expect( - AndroidSpaceshipEntranceBallContactBehavior(), - isA(), + AndroidAnimatronicBallContactBehavior(), + isA(), ); }); flameTester.test( - 'beginContact calls onBallEntered when entrance contacts with a ball', + 'beginContact calls onBallContacted when in contact with a ball', (game) async { - final behavior = AndroidSpaceshipEntranceBallContactBehavior(); + final behavior = AndroidAnimatronicBallContactBehavior(); final bloc = _MockAndroidSpaceshipCubit(); whenListen( bloc, @@ -43,20 +43,20 @@ void main() { initialState: AndroidSpaceshipState.withoutBonus, ); - final entrance = AndroidSpaceshipEntrance(); + final animatronic = AndroidAnimatronic.test(); final androidSpaceship = FlameBlocProvider.value( value: bloc, children: [ - AndroidSpaceship.test(children: [entrance]) + AndroidSpaceship.test(children: [animatronic]) ], ); - await entrance.add(behavior); + await animatronic.add(behavior); await game.ensureAdd(androidSpaceship); behavior.beginContact(_MockBall(), _MockContact()); - verify(bloc.onBallEntered).called(1); + verify(bloc.onBallContacted).called(1); }, ); }, diff --git a/packages/pinball_components/test/src/components/android_animatronic_test.dart b/packages/pinball_components/test/src/components/android_animatronic_test.dart index 65114778..55b564fe 100644 --- a/packages/pinball_components/test/src/components/android_animatronic_test.dart +++ b/packages/pinball_components/test/src/components/android_animatronic_test.dart @@ -4,6 +4,7 @@ import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_components/src/components/android_animatronic/behaviors/behaviors.dart'; import '../../helpers/helpers.dart'; @@ -58,13 +59,26 @@ void main() { }, ); - flameTester.test('adds new children', (game) async { - final component = Component(); - final androidAnimatronic = AndroidAnimatronic( - children: [component], - ); - await game.ensureAdd(androidAnimatronic); - expect(androidAnimatronic.children, contains(component)); + group('adds', () { + flameTester.test('new children', (game) async { + final component = Component(); + final androidAnimatronic = AndroidAnimatronic( + children: [component], + ); + await game.ensureAdd(androidAnimatronic); + expect(androidAnimatronic.children, contains(component)); + }); + + flameTester.test('a AndroidAnimatronicBallContactBehavior', (game) async { + final androidAnimatronic = AndroidAnimatronic(); + await game.ensureAdd(androidAnimatronic); + expect( + androidAnimatronic.children + .whereType() + .single, + isNotNull, + ); + }); }); }); } diff --git a/packages/pinball_components/test/src/components/android_spaceship/android_spaceship_test.dart b/packages/pinball_components/test/src/components/android_spaceship/android_spaceship_test.dart index 70edd32e..a282865c 100644 --- a/packages/pinball_components/test/src/components/android_spaceship/android_spaceship_test.dart +++ b/packages/pinball_components/test/src/components/android_spaceship/android_spaceship_test.dart @@ -6,7 +6,6 @@ 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/android_spaceship/behaviors/behaviors.dart'; import 'package:pinball_flame/pinball_flame.dart'; import '../../../helpers/helpers.dart'; @@ -84,26 +83,5 @@ void main() { ); }, ); - - flameTester.test( - 'AndroidSpaceshipEntrance has an ' - 'AndroidSpaceshipEntranceBallContactBehavior', (game) async { - final androidSpaceship = AndroidSpaceship(position: Vector2.zero()); - final provider = - FlameBlocProvider.value( - value: bloc, - children: [androidSpaceship], - ); - await game.ensureAdd(provider); - - final androidSpaceshipEntrance = - androidSpaceship.firstChild(); - expect( - androidSpaceshipEntrance!.children - .whereType() - .single, - isNotNull, - ); - }); }); } diff --git a/packages/pinball_components/test/src/components/android_spaceship/cubit/android_spaceship_cubit_test.dart b/packages/pinball_components/test/src/components/android_spaceship/cubit/android_spaceship_cubit_test.dart index 47b763af..f7de3674 100644 --- a/packages/pinball_components/test/src/components/android_spaceship/cubit/android_spaceship_cubit_test.dart +++ b/packages/pinball_components/test/src/components/android_spaceship/cubit/android_spaceship_cubit_test.dart @@ -9,7 +9,7 @@ void main() { blocTest( 'onBallEntered emits withBonus', build: AndroidSpaceshipCubit.new, - act: (bloc) => bloc.onBallEntered(), + act: (bloc) => bloc.onBallContacted(), expect: () => [AndroidSpaceshipState.withBonus], ); From 0ac9cb3140ba52cd1cd5f80eb7dab67ab83cab90 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Mon, 9 May 2022 19:24:57 +0100 Subject: [PATCH 7/7] fix: replaying resets game state (#441) * feat: added replay functionality * feat: resetting google word bonus * Merge remote-tracking branch 'origin' into feat/replay-functionality * test: tested Replay overlay * docs: fixed typo * test: renamed test Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> --- .../behaviors/ball_spawning_behavior.dart | 3 +- lib/game/bloc/game_bloc.dart | 2 +- .../displays/game_over_info_display.dart | 2 +- .../components/game_bloc_status_listener.dart | 11 ++++++ .../behaviors/google_word_bonus_behavior.dart | 2 +- lib/game/pinball_game.dart | 7 ++-- lib/game/view/pinball_game_page.dart | 35 ++++++++++--------- .../view/widgets/replay_button_overlay.dart | 2 ++ .../google_word_animating_behavior.dart | 2 +- .../google_word/cubit/google_word_cubit.dart | 2 +- .../google_word_animating_behavior_test.dart | 4 +-- .../cubit/google_word_cubit_test.dart | 4 +-- .../game_bloc_status_listener_test.dart | 20 +++++++++++ .../google_word_bonus_behavior_test.dart | 4 +-- test/game/view/pinball_game_page_test.dart | 17 +++++++++ .../widgets/replay_button_overlay_test.dart | 25 ++++++++++++- 16 files changed, 111 insertions(+), 31 deletions(-) diff --git a/lib/game/behaviors/ball_spawning_behavior.dart b/lib/game/behaviors/ball_spawning_behavior.dart index 8995c16b..8fc56905 100644 --- a/lib/game/behaviors/ball_spawning_behavior.dart +++ b/lib/game/behaviors/ball_spawning_behavior.dart @@ -13,7 +13,8 @@ class BallSpawningBehavior extends Component bool listenWhen(GameState? previousState, GameState newState) { if (!newState.status.isPlaying) return false; - final startedGame = previousState?.status.isWaiting ?? true; + final startedGame = (previousState?.status.isWaiting ?? true) || + (previousState?.status.isGameOver ?? true); final lostRound = (previousState?.rounds ?? newState.rounds + 1) > newState.rounds; return startedGame || lostRound; diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index c63bf514..feea7304 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -19,7 +19,7 @@ class GameBloc extends Bloc { static const _maxScore = 9999999999; void _onGameStarted(GameStarted _, Emitter emit) { - emit(state.copyWith(status: GameStatus.playing)); + emit(const GameState.initial().copyWith(status: GameStatus.playing)); } void _onGameOver(GameOver _, Emitter emit) { diff --git a/lib/game/components/backbox/displays/game_over_info_display.dart b/lib/game/components/backbox/displays/game_over_info_display.dart index 0ac160ee..2db7e20b 100644 --- a/lib/game/components/backbox/displays/game_over_info_display.dart +++ b/lib/game/components/backbox/displays/game_over_info_display.dart @@ -66,7 +66,7 @@ class GameOverInfoDisplay extends Component with HasGameRef { @override Future onLoad() async { await super.onLoad(); - gameRef.overlays.add(PinballGame.playButtonOverlay); + gameRef.overlays.add(PinballGame.replayButtonOverlay); } } diff --git a/lib/game/components/game_bloc_status_listener.dart b/lib/game/components/game_bloc_status_listener.dart index 359db070..c463cd94 100644 --- a/lib/game/components/game_bloc_status_listener.dart +++ b/lib/game/components/game_bloc_status_listener.dart @@ -22,6 +22,7 @@ class GameBlocStatusListener extends Component break; case GameStatus.playing: readProvider().play(PinballAudio.backgroundMusic); + _resetBonuses(); gameRef .descendants() .whereType() @@ -32,6 +33,7 @@ class GameBlocStatusListener extends Component .forEach(_addPlungerBehaviors); gameRef.overlays.remove(PinballGame.playButtonOverlay); + gameRef.overlays.remove(PinballGame.replayButtonOverlay); break; case GameStatus.gameOver: readProvider().play(PinballAudio.gameOverVoiceOver); @@ -54,6 +56,15 @@ class GameBlocStatusListener extends Component } } + void _resetBonuses() { + gameRef + .descendants() + .whereType>() + .single + .bloc + .onReset(); + } + void _addPlungerBehaviors(Plunger plunger) { final platformHelper = readProvider(); const pullingStrength = 7.0; diff --git a/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart b/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart index ed19f495..2313e921 100644 --- a/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart +++ b/lib/game/components/google_gallery/behaviors/google_word_bonus_behavior.dart @@ -17,7 +17,7 @@ class GoogleWordBonusBehavior extends Component { onNewState: (state) { readBloc() .add(const BonusActivated(GameBonus.googleWord)); - readBloc().onBonusAwarded(); + readBloc().onReset(); add(BonusBallSpawningBehavior()); add(GoogleWordAnimatingBehavior()); }, diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 2250a8fa..c102eb0b 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -38,10 +38,13 @@ class PinballGame extends PinballForge2DGame images.prefix = ''; } - /// Identifier of the play button overlay + /// Identifier of the play button overlay. static const playButtonOverlay = 'play_button'; - /// Identifier of the mobile controls overlay + /// Identifier of the replay button overlay. + static const replayButtonOverlay = 'replay_button'; + + /// Identifier of the mobile controls overlay. static const mobileControlsOverlay = 'mobile_controls'; @override diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index efc11996..06fde72d 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -100,22 +100,25 @@ class PinballGameLoadedView extends StatelessWidget { focusNode: game.focusNode, initialActiveOverlays: const [PinballGame.playButtonOverlay], overlayBuilderMap: { - PinballGame.playButtonOverlay: (context, game) { - return const Positioned( - bottom: 20, - right: 0, - left: 0, - child: PlayButtonOverlay(), - ); - }, - PinballGame.mobileControlsOverlay: (context, game) { - return Positioned( - bottom: 0, - left: 0, - right: 0, - child: MobileControls(game: game), - ); - }, + PinballGame.playButtonOverlay: (_, game) => const Positioned( + bottom: 20, + right: 0, + left: 0, + child: PlayButtonOverlay(), + ), + PinballGame.mobileControlsOverlay: (_, game) => Positioned( + bottom: 0, + left: 0, + right: 0, + child: MobileControls(game: game), + ), + PinballGame.replayButtonOverlay: (context, game) => + const Positioned( + bottom: 20, + right: 0, + left: 0, + child: ReplayButtonOverlay(), + ) }, ), ), diff --git a/lib/game/view/widgets/replay_button_overlay.dart b/lib/game/view/widgets/replay_button_overlay.dart index c0b2a67d..806f6ed7 100644 --- a/lib/game/view/widgets/replay_button_overlay.dart +++ b/lib/game/view/widgets/replay_button_overlay.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_ui/pinball_ui.dart'; @@ -18,6 +19,7 @@ class ReplayButtonOverlay extends StatelessWidget { return PinballButton( text: l10n.replay, onTap: () { + context.read().add(const GameStarted()); context.read().add(const ReplayTapped()); }, ); diff --git a/packages/pinball_components/lib/src/components/google_word/behaviors/google_word_animating_behavior.dart b/packages/pinball_components/lib/src/components/google_word/behaviors/google_word_animating_behavior.dart index c16d4a2e..2119c2f8 100644 --- a/packages/pinball_components/lib/src/components/google_word/behaviors/google_word_animating_behavior.dart +++ b/packages/pinball_components/lib/src/components/google_word/behaviors/google_word_animating_behavior.dart @@ -17,7 +17,7 @@ class GoogleWordAnimatingBehavior extends TimerComponent _blinks++; } else { timer.stop(); - bloc.onAnimationFinished(); + bloc.onReset(); shouldRemove = true; } } diff --git a/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart b/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart index 8a8b976d..cd69fc9d 100644 --- a/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart +++ b/packages/pinball_components/lib/src/components/google_word/cubit/google_word_cubit.dart @@ -68,7 +68,7 @@ class GoogleWordCubit extends Cubit { ); } - void onAnimationFinished() { + void onReset() { emit(GoogleWordState.initial()); _lastLitLetter = 0; } diff --git a/packages/pinball_components/test/src/components/google_word/behaviors/google_word_animating_behavior_test.dart b/packages/pinball_components/test/src/components/google_word/behaviors/google_word_animating_behavior_test.dart index 7224aeed..6275678c 100644 --- a/packages/pinball_components/test/src/components/google_word/behaviors/google_word_animating_behavior_test.dart +++ b/packages/pinball_components/test/src/components/google_word/behaviors/google_word_animating_behavior_test.dart @@ -41,7 +41,7 @@ void main() { ); flameTester.testGameWidget( - 'calls onAnimationFinished and removes itself ' + 'calls onReset and removes itself ' 'after all blinks complete', setUp: (game, tester) async { final behavior = GoogleWordAnimatingBehavior(); @@ -53,7 +53,7 @@ void main() { } await game.ready(); - verify(bloc.onAnimationFinished).called(1); + verify(bloc.onReset).called(1); expect( game.descendants().whereType().isEmpty, isTrue, diff --git a/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart b/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart index b5000387..152b5f96 100644 --- a/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart +++ b/packages/pinball_components/test/src/components/google_word/cubit/google_word_cubit_test.dart @@ -62,9 +62,9 @@ void main() { ); blocTest( - 'onAnimationFinished emits initial state', + 'onReset emits initial state', build: GoogleWordCubit.new, - act: (bloc) => bloc.onAnimationFinished(), + act: (bloc) => bloc.onReset(), expect: () => [GoogleWordState.initial()], ); }, diff --git a/test/game/components/game_bloc_status_listener_test.dart b/test/game/components/game_bloc_status_listener_test.dart index 3519dbbd..874f901c 100644 --- a/test/game/components/game_bloc_status_listener_test.dart +++ b/test/game/components/game_bloc_status_listener_test.dart @@ -37,6 +37,7 @@ class _TestGame extends Forge2DGame with HasTappables { Iterable children, { PinballAudioPlayer? pinballAudioPlayer, PlatformHelper? platformHelper, + GoogleWordCubit? googleWordBloc, }) async { return ensureAdd( FlameMultiBlocProvider( @@ -47,6 +48,9 @@ class _TestGame extends Forge2DGame with HasTappables { FlameBlocProvider.value( value: CharacterThemeCubit(), ), + FlameBlocProvider.value( + value: googleWordBloc ?? GoogleWordCubit(), + ), ], children: [ MultiFlameProvider( @@ -80,6 +84,8 @@ class _MockPlatformHelper extends Mock implements PlatformHelper {} class _MockPlungerCubit extends Mock implements PlungerCubit {} +class _MockGoogleWordCubit extends Mock implements GoogleWordCubit {} + class _MockAppLocalizations extends Mock implements AppLocalizations { @override String get score => ''; @@ -332,6 +338,20 @@ void main() { }, ); + flameTester.test( + 'resets the GoogleWordCubit', + (game) async { + final googleWordBloc = _MockGoogleWordCubit(); + final component = GameBlocStatusListener(); + await game.pump([component], googleWordBloc: googleWordBloc); + + expect(state.status, equals(GameStatus.playing)); + component.onNewState(state); + + verify(googleWordBloc.onReset).called(1); + }, + ); + flameTester.test( 'adds FlipperKeyControllingBehavior to Flippers', (game) async { diff --git a/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart b/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart index acabe4c7..17726156 100644 --- a/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart +++ b/test/game/components/google_gallery/behaviors/google_word_bonus_behavior_test.dart @@ -75,7 +75,7 @@ void main() { flameTester.testGameWidget( 'adds GameBonus.googleWord to the game when all letters ' - 'in google word are activated and calls onBonusAwarded', + 'in google word are activated and calls onReset', setUp: (game, tester) async { final behavior = GoogleWordBonusBehavior(); final parent = GoogleGallery.test(); @@ -114,7 +114,7 @@ void main() { verify( () => gameBloc.add(const BonusActivated(GameBonus.googleWord)), ).called(1); - verify(googleWordBloc.onBonusAwarded).called(1); + verify(googleWordBloc.onReset).called(1); }, ); diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index a0a0f22c..669117ed 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -307,6 +307,23 @@ void main() { expect(find.byType(MobileControls), findsOneWidget); }); + testWidgets( + 'ReplayButtonOverlay when the overlay is added', + (tester) async { + await tester.pumpApp( + PinballGameView(game), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + + game.overlays.add(PinballGame.replayButtonOverlay); + + await tester.pump(); + + expect(find.byType(ReplayButtonOverlay), findsOneWidget); + }, + ); + group('info icon', () { testWidgets('renders on game over', (tester) async { final gameState = GameState.initial().copyWith( diff --git a/test/game/view/widgets/replay_button_overlay_test.dart b/test/game/view/widgets/replay_button_overlay_test.dart index 1497031a..5c3e4884 100644 --- a/test/game/view/widgets/replay_button_overlay_test.dart +++ b/test/game/view/widgets/replay_button_overlay_test.dart @@ -8,24 +8,32 @@ import '../../../helpers/helpers.dart'; class _MockStartGameBloc extends Mock implements StartGameBloc {} +class _MockGameBloc extends Mock implements GameBloc {} + void main() { group('ReplayButtonOverlay', () { late StartGameBloc startGameBloc; + late _MockGameBloc gameBloc; setUp(() async { await mockFlameImages(); startGameBloc = _MockStartGameBloc(); + gameBloc = _MockGameBloc(); whenListen( startGameBloc, Stream.value(const StartGameState.initial()), initialState: const StartGameState.initial(), ); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); }); testWidgets('renders correctly', (tester) async { await tester.pumpApp(const ReplayButtonOverlay()); - expect(find.text('Replay'), findsOneWidget); }); @@ -33,6 +41,7 @@ void main() { (tester) async { await tester.pumpApp( const ReplayButtonOverlay(), + gameBloc: gameBloc, startGameBloc: startGameBloc, ); @@ -41,5 +50,19 @@ void main() { verify(() => startGameBloc.add(const ReplayTapped())).called(1); }); + + testWidgets('adds GameStarted event to GameBloc when tapped', + (tester) async { + await tester.pumpApp( + const ReplayButtonOverlay(), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + + await tester.tap(find.text('Replay')); + await tester.pump(); + + verify(() => gameBloc.add(const GameStarted())).called(1); + }); }); }