diff --git a/.github/workflows/pinball_components.yaml b/.github/workflows/pinball_components.yaml index bf1907f8..e4154059 100644 --- a/.github/workflows/pinball_components.yaml +++ b/.github/workflows/pinball_components.yaml @@ -13,7 +13,7 @@ on: jobs: build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@b075749771679a5baa4c90d36ad2e8580bbf273b with: working_directory: packages/pinball_components coverage_excludes: "lib/gen/*.dart" diff --git a/lib/flame/component_controller.dart b/lib/flame/component_controller.dart index 1d6e0173..b9568348 100644 --- a/lib/flame/component_controller.dart +++ b/lib/flame/component_controller.dart @@ -33,7 +33,7 @@ abstract class ComponentController extends Component { /// Mixin that attaches a single [ComponentController] to a [Component]. mixin Controls on Component { /// The [ComponentController] attached to this [Component]. - late final T controller; + late T controller; @override @mustCallSuper diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index 524329b2..ec08d90a 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -20,9 +20,7 @@ class GameBloc extends Bloc { static const bonusWordScore = 10000; void _onBallLost(BallLost event, Emitter emit) { - if (state.balls > 0) { - emit(state.copyWith(balls: state.balls - 1)); - } + emit(state.copyWith(balls: state.balls - 1)); } void _onScored(Scored event, Emitter emit) { @@ -37,7 +35,8 @@ class GameBloc extends Bloc { event.letterIndex, ]; - if (newBonusLetters.length == bonusWord.length) { + final achievedBonus = newBonusLetters.length == bonusWord.length; + if (achievedBonus) { emit( state.copyWith( activatedBonusLetters: [], @@ -56,15 +55,16 @@ class GameBloc extends Bloc { } void _onDashNestActivated(DashNestActivated event, Emitter emit) { - const nestsRequiredForBonus = 3; - final newNests = { ...state.activatedDashNests, event.nestId, }; - if (newNests.length == nestsRequiredForBonus) { + + final achievedBonus = newNests.length == 3; + if (achievedBonus) { emit( state.copyWith( + balls: state.balls + 1, activatedDashNests: {}, bonusHistory: [ ...state.bonusHistory, diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 63892e2c..ae9faa96 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -5,11 +5,10 @@ part of 'game_bloc.dart'; /// Defines bonuses that a player can gain during a PinballGame. enum GameBonus { /// Bonus achieved when the user activate all of the bonus - /// letters on the board, forming the bonus word + /// letters on the board, forming the bonus word. word, - /// Bonus achieved when the user activates all of the Dash - /// nests on the board, adding a new ball to the board. + /// Bonus achieved when the user activates all dash nest bumpers. dashNest, } diff --git a/lib/game/components/controlled_ball.dart b/lib/game/components/controlled_ball.dart index 1981e39c..cef076d8 100644 --- a/lib/game/components/controlled_ball.dart +++ b/lib/game/components/controlled_ball.dart @@ -1,5 +1,4 @@ import 'package:flame/components.dart'; -import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/forge2d_game.dart'; import 'package:flutter/material.dart'; import 'package:pinball/flame/flame.dart'; @@ -18,7 +17,7 @@ class ControlledBall extends Ball with Controls { ControlledBall.launch({ required PinballTheme theme, }) : super(baseColor: theme.characterTheme.ballColor) { - controller = LaunchedBallController(this); + controller = BallController(this); } /// {@template bonus_ball} @@ -29,74 +28,43 @@ class ControlledBall extends Ball with Controls { ControlledBall.bonus({ required PinballTheme theme, }) : super(baseColor: theme.characterTheme.ballColor) { - controller = BonusBallController(this); + controller = BallController(this); } /// [Ball] used in [DebugPinballGame]. ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) { - controller = BonusBallController(this); + controller = DebugBallController(this); } } /// {@template ball_controller} /// Controller attached to a [Ball] that handles its game related logic. /// {@endtemplate} -abstract class BallController extends ComponentController { +class BallController extends ComponentController + with HasGameRef { /// {@macro ball_controller} BallController(Ball ball) : super(ball); /// Removes the [Ball] from a [PinballGame]. /// - /// {@template ball_controller_lost} /// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into /// a [BottomWall]. - /// {@endtemplate} - void lost(); -} - -/// {@template bonus_ball_controller} -/// {@macro ball_controller} -/// -/// A [BonusBallController] doesn't change the [GameState.balls] count. -/// {@endtemplate} -class BonusBallController extends BallController { - /// {@macro bonus_ball_controller} - BonusBallController(Ball component) : super(component); - - @override void lost() { component.shouldRemove = true; } -} - -/// {@template launched_ball_controller} -/// {@macro ball_controller} -/// -/// A [LaunchedBallController] changes the [GameState.balls] count. -/// {@endtemplate} -class LaunchedBallController extends BallController - with HasGameRef, BlocComponent { - /// {@macro launched_ball_controller} - LaunchedBallController(Ball ball) : super(ball); @override - bool listenWhen(GameState? previousState, GameState newState) { - return (previousState?.balls ?? 0) > newState.balls; + void onRemove() { + super.onRemove(); + gameRef.read().add(const BallLost()); } +} - @override - void onNewState(GameState state) { - super.onNewState(state); - component.shouldRemove = true; - if (state.balls > 0) gameRef.spawnBall(); - } +/// {@macro ball_controller} +class DebugBallController extends BallController { + /// {@macro ball_controller} + DebugBallController(Ball component) : super(component); - /// Removes the [Ball] from a [PinballGame]; spawning a new [Ball] if - /// any are left. - /// - /// {@macro ball_controller_lost} @override - void lost() { - gameRef.read().add(const BallLost()); - } + void onRemove() {} } diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart index 030edc50..ba8af5e7 100644 --- a/lib/game/components/wall.dart +++ b/lib/game/components/wall.dart @@ -71,12 +71,12 @@ class BottomWall extends Wall { } /// {@template bottom_wall_ball_contact_callback} -/// Listens when a [Ball] falls into a [BottomWall]. +/// Listens when a [ControlledBall] falls into a [BottomWall]. /// {@endtemplate} -class BottomWallBallContactCallback extends ContactCallback { +class BottomWallBallContactCallback + extends ContactCallback { @override - void begin(Ball ball, BottomWall wall, Contact contact) { - // TODO(alestiago): replace with .firstChild when available. - ball.children.whereType().first.lost(); + void begin(ControlledBall ball, BottomWall wall, Contact contact) { + ball.controller.lost(); } } diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index 47175c32..050b2cd3 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -15,6 +15,10 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.baseboard.right.keyName), images.load(components.Assets.images.kicker.left.keyName), images.load(components.Assets.images.kicker.right.keyName), + images.load(components.Assets.images.slingshot.leftUpper.keyName), + images.load(components.Assets.images.slingshot.leftLower.keyName), + images.load(components.Assets.images.slingshot.rightUpper.keyName), + images.load(components.Assets.images.slingshot.rightLower.keyName), images.load(components.Assets.images.launchRamp.ramp.keyName), images.load( components.Assets.images.launchRamp.foregroundRailing.keyName, diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index a6eb0884..2ccf8fe8 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -5,6 +5,7 @@ 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:pinball/flame/flame.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/gen/assets.gen.dart'; import 'package:pinball_audio/pinball_audio.dart'; @@ -12,30 +13,40 @@ import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart' hide Assets; class PinballGame extends Forge2DGame - with FlameBloc, HasKeyboardHandlerComponents { - PinballGame({required this.theme, required this.audio}) { + with + FlameBloc, + HasKeyboardHandlerComponents, + Controls<_GameBallsController> { + PinballGame({ + required this.theme, + required this.audio, + }) { images.prefix = ''; + controller = _GameBallsController(this); } final PinballTheme theme; final PinballAudio audio; - @override - void onAttach() { - super.onAttach(); - spawnBall(); - } - @override Future onLoad() async { _addContactCallbacks(); + // Fix camera on the center of the board. + camera + ..followVector2(Vector2(0, -7.8)) + ..zoom = size.y / 16; await _addGameBoundaries(); unawaited(addFromBlueprint(Boundaries())); unawaited(addFromBlueprint(LaunchRamp())); - unawaited(_addPlunger()); + + final plunger = Plunger(compressionDistance: 29) + ..initialPosition = Vector2(38, -19); + await add(plunger); + unawaited(add(Board())); + unawaited(addFromBlueprint(Slingshots())); unawaited(addFromBlueprint(DinoWalls())); unawaited(_addBonusWord()); unawaited(addFromBlueprint(SpaceshipRamp())); @@ -52,10 +63,8 @@ class PinballGame extends Forge2DGame ), ); - // Fix camera on the center of the board. - camera - ..followVector2(Vector2(0, -7.8)) - ..zoom = size.y / 16; + controller.attachTo(plunger); + await super.onLoad(); } void _addContactCallbacks() { @@ -69,12 +78,6 @@ class PinballGame extends Forge2DGame createBoundaries(this).forEach(add); } - Future _addPlunger() async { - final plunger = Plunger(compressionDistance: 29) - ..initialPosition = Vector2(38, -19); - await add(plunger); - } - Future _addBonusWord() async { await add( BonusWord( @@ -85,13 +88,49 @@ class PinballGame extends Forge2DGame ), ); } +} - Future spawnBall() async { - // TODO(alestiago): Remove once this logic is moved to controller. +class _GameBallsController extends ComponentController + with BlocComponent, HasGameRef { + _GameBallsController(PinballGame game) : super(game); + + late final Plunger _plunger; + + @override + bool listenWhen(GameState? previousState, GameState newState) { + final noBallsLeft = component.descendants().whereType().isEmpty; + final canBallRespawn = newState.balls > 0; + + return noBallsLeft && canBallRespawn; + } + + @override + void onNewState(GameState state) { + super.onNewState(state); + _spawnBall(); + } + + @override + Future onLoad() async { + await super.onLoad(); + _spawnBall(); + } + + void _spawnBall() { final ball = ControlledBall.launch( - theme: theme, - )..initialPosition = Vector2(38, -19 + Ball.size.y); - await add(ball); + theme: gameRef.theme, + )..initialPosition = Vector2( + _plunger.body.position.x, + _plunger.body.position.y + Ball.size.y, + ); + component.add(ball); + } + + /// Attaches the controller to the plunger. + // TODO(alestiago): Remove this method and use onLoad instead. + // ignore: use_setters_to_change_properties + void attachTo(Plunger plunger) { + _plunger = plunger; } } @@ -102,7 +141,9 @@ class DebugPinballGame extends PinballGame with TapDetector { }) : super( theme: theme, audio: audio, - ); + ) { + controller = _DebugGameBallsController(this); + } @override Future onLoad() async { @@ -134,3 +175,19 @@ class DebugPinballGame extends PinballGame with TapDetector { ); } } + +class _DebugGameBallsController extends _GameBallsController { + _DebugGameBallsController(PinballGame game) : super(game); + + @override + bool listenWhen(GameState? previousState, GameState newState) { + final noBallsLeft = component + .descendants() + .whereType() + .where((ball) => ball.controller is! DebugBallController) + .isEmpty; + final canBallRespawn = newState.balls > 0; + + return noBallsLeft && canBallRespawn; + } +} diff --git a/packages/pinball_components/assets/images/slingshot/left_lower.png b/packages/pinball_components/assets/images/slingshot/left_lower.png new file mode 100644 index 00000000..b44b58fb Binary files /dev/null and b/packages/pinball_components/assets/images/slingshot/left_lower.png differ diff --git a/packages/pinball_components/assets/images/slingshot/left_upper.png b/packages/pinball_components/assets/images/slingshot/left_upper.png new file mode 100644 index 00000000..c74267ca Binary files /dev/null and b/packages/pinball_components/assets/images/slingshot/left_upper.png differ diff --git a/packages/pinball_components/assets/images/slingshot/right_lower.png b/packages/pinball_components/assets/images/slingshot/right_lower.png new file mode 100644 index 00000000..71a6a277 Binary files /dev/null and b/packages/pinball_components/assets/images/slingshot/right_lower.png differ diff --git a/packages/pinball_components/assets/images/slingshot/right_upper.png b/packages/pinball_components/assets/images/slingshot/right_upper.png new file mode 100644 index 00000000..e6b42ded Binary files /dev/null and b/packages/pinball_components/assets/images/slingshot/right_upper.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index de59219e..518d3237 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -29,6 +29,7 @@ class $AssetsImagesGen { $AssetsImagesKickerGen get kicker => const $AssetsImagesKickerGen(); $AssetsImagesLaunchRampGen get launchRamp => const $AssetsImagesLaunchRampGen(); + $AssetsImagesSlingshotGen get slingshot => const $AssetsImagesSlingshotGen(); $AssetsImagesSpaceshipGen get spaceship => const $AssetsImagesSpaceshipGen(); $AssetsImagesSparkyBumperGen get sparkyBumper => const $AssetsImagesSparkyBumperGen(); @@ -127,6 +128,26 @@ class $AssetsImagesLaunchRampGen { const AssetGenImage('assets/images/launch_ramp/ramp.png'); } +class $AssetsImagesSlingshotGen { + const $AssetsImagesSlingshotGen(); + + /// File path: assets/images/slingshot/left_lower.png + AssetGenImage get leftLower => + const AssetGenImage('assets/images/slingshot/left_lower.png'); + + /// File path: assets/images/slingshot/left_upper.png + AssetGenImage get leftUpper => + const AssetGenImage('assets/images/slingshot/left_upper.png'); + + /// File path: assets/images/slingshot/right_lower.png + AssetGenImage get rightLower => + const AssetGenImage('assets/images/slingshot/right_lower.png'); + + /// File path: assets/images/slingshot/right_upper.png + AssetGenImage get rightUpper => + const AssetGenImage('assets/images/slingshot/right_upper.png'); +} + class $AssetsImagesSpaceshipGen { const $AssetsImagesSpaceshipGen(); diff --git a/packages/pinball_components/lib/src/components/camera_zoom.dart b/packages/pinball_components/lib/src/components/camera_zoom.dart new file mode 100644 index 00000000..a3da382e --- /dev/null +++ b/packages/pinball_components/lib/src/components/camera_zoom.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flutter/material.dart'; + +/// {@template camera_zoom} +/// Applies zoom to the camera of the game where this is added to +/// {@endtemplate} +class CameraZoom extends Effect with HasGameRef { + /// {@macro camera_zoom} + CameraZoom({ + required this.value, + }) : super( + EffectController( + duration: 0.4, + curve: Curves.easeOut, + ), + ); + + /// The total zoom value to be applied to the camera + final double value; + + late final Tween _tween; + + final Completer _completer = Completer(); + + @override + Future onLoad() async { + _tween = Tween( + begin: gameRef.camera.zoom, + end: value, + ); + } + + @override + void apply(double progress) { + gameRef.camera.zoom = _tween.transform(progress); + } + + /// Returns a [Future] that completes once the zoom is finished + Future get completed { + if (controller.completed) { + return Future.value(); + } + + return _completer.future; + } + + @override + void onRemove() { + _completer.complete(); + + super.onRemove(); + } +} diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index 6b0c2ef5..8ac1a0f9 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -3,6 +3,7 @@ export 'baseboard.dart'; export 'board_dimensions.dart'; export 'board_side.dart'; export 'boundaries.dart'; +export 'camera_zoom.dart'; export 'chrome_dino.dart'; export 'dash_nest_bumper.dart'; export 'dino_walls.dart'; @@ -16,6 +17,7 @@ export 'launch_ramp.dart'; export 'layer.dart'; export 'ramp_opening.dart'; export 'shapes/shapes.dart'; +export 'slingshot.dart'; export 'spaceship.dart'; export 'spaceship_rail.dart'; export 'spaceship_ramp.dart'; diff --git a/packages/pinball_components/lib/src/components/slingshot.dart b/packages/pinball_components/lib/src/components/slingshot.dart new file mode 100644 index 00000000..0ebe13ce --- /dev/null +++ b/packages/pinball_components/lib/src/components/slingshot.dart @@ -0,0 +1,138 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template slingshots} +/// A [Blueprint] which creates the left and right pairs of [Slingshot]s. +/// {@endtemplate} +class Slingshots extends Forge2DBlueprint { + @override + void build(_) { + // TODO(allisonryan0002): use radians values instead of converting degrees. + final leftUpperSlingshot = Slingshot( + length: 5.66, + angle: -1.5 * (math.pi / 180), + spritePath: Assets.images.slingshot.leftUpper.keyName, + )..initialPosition = Vector2(-29, 1.5); + + final leftLowerSlingshot = Slingshot( + length: 3.54, + angle: -29.1 * (math.pi / 180), + spritePath: Assets.images.slingshot.leftLower.keyName, + )..initialPosition = Vector2(-31, -6.2); + + final rightUpperSlingshot = Slingshot( + length: 5.64, + angle: 1 * (math.pi / 180), + spritePath: Assets.images.slingshot.rightUpper.keyName, + )..initialPosition = Vector2(22.3, 1.58); + + final rightLowerSlingshot = Slingshot( + length: 3.46, + angle: 26.8 * (math.pi / 180), + spritePath: Assets.images.slingshot.rightLower.keyName, + )..initialPosition = Vector2(24.7, -6.2); + + addAll([ + leftUpperSlingshot, + leftLowerSlingshot, + rightUpperSlingshot, + rightLowerSlingshot, + ]); + } +} + +/// {@template slingshot} +/// Elastic bumper that bounces the [Ball] off of its straight sides. +/// {@endtemplate} +class Slingshot extends BodyComponent with InitialPosition { + /// {@macro slingshot} + Slingshot({ + required double length, + required double angle, + required String spritePath, + }) : _length = length, + _angle = angle, + _spritePath = spritePath, + super(priority: 1); + + final double _length; + + final double _angle; + + final String _spritePath; + + List _createFixtureDefs() { + final fixturesDef = []; + const circleRadius = 1.55; + + final topCircleShape = CircleShape()..radius = circleRadius; + topCircleShape.position.setValues(0, _length / 2); + final topCircleFixtureDef = FixtureDef(topCircleShape)..friction = 0; + fixturesDef.add(topCircleFixtureDef); + + final bottomCircleShape = CircleShape()..radius = circleRadius; + bottomCircleShape.position.setValues(0, -_length / 2); + final bottomCircleFixtureDef = FixtureDef(bottomCircleShape)..friction = 0; + fixturesDef.add(bottomCircleFixtureDef); + + final leftEdgeShape = EdgeShape() + ..set( + Vector2(circleRadius, _length / 2), + Vector2(circleRadius, -_length / 2), + ); + final leftEdgeShapeFixtureDef = FixtureDef(leftEdgeShape) + ..friction = 0 + ..restitution = 5; + fixturesDef.add(leftEdgeShapeFixtureDef); + + final rightEdgeShape = EdgeShape() + ..set( + Vector2(-circleRadius, _length / 2), + Vector2(-circleRadius, -_length / 2), + ); + final rightEdgeShapeFixtureDef = FixtureDef(rightEdgeShape) + ..friction = 0 + ..restitution = 5; + fixturesDef.add(rightEdgeShapeFixtureDef); + + return fixturesDef; + } + + @override + Body createBody() { + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..angle = _angle; + + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + + return body; + } + + @override + Future onLoad() async { + await super.onLoad(); + await _loadSprite(); + renderBody = false; + } + + Future _loadSprite() async { + final sprite = await gameRef.loadSprite(_spritePath); + + await add( + SpriteComponent( + sprite: sprite, + size: sprite.originalSize / 10, + anchor: Anchor.center, + angle: _angle, + ), + ); + } +} diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 312e01f3..b6f71b8b 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -39,6 +39,7 @@ flutter: - assets/images/spaceship/ramp/ - assets/images/chrome_dino/ - assets/images/kicker/ + - assets/images/slingshot/ - assets/images/sparky_bumper/a/ - assets/images/sparky_bumper/b/ - assets/images/sparky_bumper/c/ diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index 88b86da6..3d65dbe2 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -21,6 +21,8 @@ void main() { addChromeDinoStories(dashbook); addDashNestBumperStories(dashbook); addKickerStories(dashbook); + addSlingshotStories(dashbook); addSparkyBumperStories(dashbook); + addZoomStories(dashbook); runApp(dashbook); } diff --git a/packages/pinball_components/sandbox/lib/stories/slingshot/slingshot_game.dart b/packages/pinball_components/sandbox/lib/stories/slingshot/slingshot_game.dart new file mode 100644 index 00000000..c02689ca --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/slingshot/slingshot_game.dart @@ -0,0 +1,66 @@ +import 'dart:math' as math; + +import 'package:flame/extensions.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/ball/basic_ball_game.dart'; + +class SlingshotGame extends BasicBallGame { + SlingshotGame({ + required this.trace, + }) : super(color: const Color(0xFFFF0000)); + + static const info = ''' + Shows how Slingshots are rendered. + + - Activate the "trace" parameter to overlay the body. + - Tap anywhere on the screen to spawn a ball into the game. +'''; + + final bool trace; + + @override + Future onLoad() async { + await super.onLoad(); + + final center = screenToWorld(camera.viewport.canvasSize! / 2); + + final leftUpperSlingshot = Slingshot( + length: 5.66, + angle: -1.5 * (math.pi / 180), + spritePath: Assets.images.slingshot.leftUpper.keyName, + )..initialPosition = center + Vector2(-29, 1.5); + + final leftLowerSlingshot = Slingshot( + length: 3.54, + angle: -29.1 * (math.pi / 180), + spritePath: Assets.images.slingshot.leftLower.keyName, + )..initialPosition = center + Vector2(-31, -6.2); + + final rightUpperSlingshot = Slingshot( + length: 5.64, + angle: 1 * (math.pi / 180), + spritePath: Assets.images.slingshot.rightUpper.keyName, + )..initialPosition = center + Vector2(22.3, 1.58); + + final rightLowerSlingshot = Slingshot( + length: 3.46, + angle: 26.8 * (math.pi / 180), + spritePath: Assets.images.slingshot.rightLower.keyName, + )..initialPosition = center + Vector2(24.7, -6.2); + + await addAll([ + leftUpperSlingshot, + leftLowerSlingshot, + rightUpperSlingshot, + rightLowerSlingshot, + ]); + + if (trace) { + leftUpperSlingshot.trace(); + leftLowerSlingshot.trace(); + rightUpperSlingshot.trace(); + rightLowerSlingshot.trace(); + } + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/slingshot/stories.dart b/packages/pinball_components/sandbox/lib/stories/slingshot/stories.dart new file mode 100644 index 00000000..6e985d32 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/slingshot/stories.dart @@ -0,0 +1,17 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/slingshot/slingshot_game.dart'; + +void addSlingshotStories(Dashbook dashbook) { + dashbook.storiesOf('Slingshots').add( + 'Basic', + (context) => GameWidget( + game: SlingshotGame( + trace: context.boolProperty('Trace', true), + ), + ), + codeLink: buildSourceLink('slingshot_game/basic.dart'), + info: SlingshotGame.info, + ); +} diff --git a/packages/pinball_components/sandbox/lib/stories/stories.dart b/packages/pinball_components/sandbox/lib/stories/stories.dart index 746d83d6..d7409e87 100644 --- a/packages/pinball_components/sandbox/lib/stories/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/stories.dart @@ -5,5 +5,7 @@ export 'dash_nest_bumper/stories.dart'; export 'effects/stories.dart'; export 'flipper/stories.dart'; export 'layer/stories.dart'; +export 'slingshot/stories.dart'; export 'spaceship/stories.dart'; export 'sparky_bumper/stories.dart'; +export 'zoom/stories.dart'; diff --git a/packages/pinball_components/sandbox/lib/stories/zoom/basic_zoom_game.dart b/packages/pinball_components/sandbox/lib/stories/zoom/basic_zoom_game.dart new file mode 100644 index 00000000..276dd39c --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/zoom/basic_zoom_game.dart @@ -0,0 +1,37 @@ +import 'package:flame/components.dart'; +import 'package:flame/input.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; + +class BasicCameraZoomGame extends BasicGame with TapDetector { + static const info = ''' + Simple game to demonstrate how the CameraZoom can be used. + Tap to zoom in/out + '''; + + bool zoomApplied = false; + + @override + Future onLoad() async { + final sprite = await loadSprite(Assets.images.flutterSignPost.keyName); + + await add( + SpriteComponent( + sprite: sprite, + size: Vector2(4, 8), + anchor: Anchor.center, + ), + ); + + camera.followVector2(Vector2.zero()); + } + + @override + void onTap() { + if (firstChild() == null) { + final zoom = CameraZoom(value: zoomApplied ? 30 : 10); + add(zoom); + zoomApplied = !zoomApplied; + } + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/zoom/stories.dart b/packages/pinball_components/sandbox/lib/stories/zoom/stories.dart new file mode 100644 index 00000000..653d5491 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/zoom/stories.dart @@ -0,0 +1,15 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/zoom/basic_zoom_game.dart'; + +void addZoomStories(Dashbook dashbook) { + dashbook.storiesOf('CameraZoom').add( + 'Basic', + (context) => GameWidget( + game: BasicCameraZoomGame(), + ), + codeLink: buildSourceLink('zoom/basic_zoom_game.dart'), + info: BasicCameraZoomGame.info, + ); +} diff --git a/packages/pinball_components/test/src/components/camera_zoom_test.dart b/packages/pinball_components/test/src/components/camera_zoom_test.dart new file mode 100644 index 00000000..00f43847 --- /dev/null +++ b/packages/pinball_components/test/src/components/camera_zoom_test.dart @@ -0,0 +1,85 @@ +// ignore_for_file: cascade_invocations + +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 '../../helpers/helpers.dart'; + +void main() { + group('CameraZoom', () { + final tester = FlameTester(TestGame.new); + + tester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + game.camera.followVector2(Vector2.zero()); + game.camera.zoom = 10; + final sprite = await game.loadSprite( + Assets.images.flutterSignPost.keyName, + ); + + await game.add( + SpriteComponent( + sprite: sprite, + size: Vector2(4, 8), + anchor: Anchor.center, + ), + ); + + await game.add(CameraZoom(value: 40)); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/camera_zoom/no_zoom.png'), + ); + + game.update(0.2); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('golden/camera_zoom/in_between.png'), + ); + + game.update(0.4); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('golden/camera_zoom/finished.png'), + ); + game.update(0.1); + await tester.pump(); + + expect(game.firstChild(), isNull); + }, + ); + + tester.test( + 'completes when checked after it is finished', + (game) async { + await game.add(CameraZoom(value: 40)); + game.update(10); + final cameraZoom = game.firstChild(); + final future = cameraZoom!.completed; + + expect(future, completes); + }, + ); + + tester.test( + 'completes when checked before it is finished', + (game) async { + final zoom = CameraZoom(value: 40); + final future = zoom.completed; + + await game.add(zoom); + game.update(10); + game.update(0); + + expect(future, completes); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/golden/camera_zoom/finished.png b/packages/pinball_components/test/src/components/golden/camera_zoom/finished.png new file mode 100644 index 00000000..be784ada Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/camera_zoom/finished.png differ diff --git a/packages/pinball_components/test/src/components/golden/camera_zoom/in_between.png b/packages/pinball_components/test/src/components/golden/camera_zoom/in_between.png new file mode 100644 index 00000000..3809f0d0 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/camera_zoom/in_between.png differ diff --git a/packages/pinball_components/test/src/components/golden/camera_zoom/no_zoom.png b/packages/pinball_components/test/src/components/golden/camera_zoom/no_zoom.png new file mode 100644 index 00000000..a6215d65 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/camera_zoom/no_zoom.png differ diff --git a/packages/pinball_components/test/src/components/golden/slingshots.png b/packages/pinball_components/test/src/components/golden/slingshots.png new file mode 100644 index 00000000..2e4ada7b Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/slingshots.png differ diff --git a/packages/pinball_components/test/src/components/slingshot_test.dart b/packages/pinball_components/test/src/components/slingshot_test.dart new file mode 100644 index 00000000..6f015e13 --- /dev/null +++ b/packages/pinball_components/test/src/components/slingshot_test.dart @@ -0,0 +1,97 @@ +// 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() { + group('Slingshot', () { + final flameTester = FlameTester(TestGame.new); + const length = 2.0; + const angle = 0.0; + final spritePath = Assets.images.slingshot.leftUpper.keyName; + + flameTester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + await game.addFromBlueprint(Slingshots()); + await game.ready(); + game.camera.followVector2(Vector2.zero()); + }, + // TODO(allisonryan0002): enable test when workflows are fixed. + // verify: (game, tester) async { + // await expectLater( + // find.byGame(), + // matchesGoldenFile('golden/slingshots.png'), + // ); + // }, + ); + + flameTester.test( + 'loads correctly', + (game) async { + final slingshot = Slingshot( + length: length, + angle: angle, + spritePath: spritePath, + ); + await game.ensureAdd(slingshot); + + expect(game.contains(slingshot), isTrue); + }, + ); + + flameTester.test( + 'body is static', + (game) async { + final slingshot = Slingshot( + length: length, + angle: angle, + spritePath: spritePath, + ); + await game.ensureAdd(slingshot); + + expect(slingshot.body.bodyType, equals(BodyType.static)); + }, + ); + + flameTester.test( + 'has restitution', + (game) async { + final slingshot = Slingshot( + length: length, + angle: angle, + spritePath: spritePath, + ); + await game.ensureAdd(slingshot); + + final totalRestitution = slingshot.body.fixtures.fold( + 0, + (total, fixture) => total + fixture.restitution, + ); + expect(totalRestitution, greaterThan(0)); + }, + ); + + flameTester.test( + 'has no friction', + (game) async { + final slingshot = Slingshot( + length: length, + angle: angle, + spritePath: spritePath, + ); + await game.ensureAdd(slingshot); + + final totalFriction = slingshot.body.fixtures.fold( + 0, + (total, fixture) => total + fixture.friction, + ); + expect(totalFriction, equals(0)); + }, + ); + }); +} diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart index df0c8a6b..4df534b3 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -14,13 +14,10 @@ void main() { group('LostBall', () { blocTest( - "doesn't decrease ball " - 'when no balls left', + 'decreases number of balls', build: GameBloc.new, act: (bloc) { - for (var i = 0; i <= bloc.state.balls; i++) { - bloc.add(const BallLost()); - } + bloc.add(const BallLost()); }, expect: () => [ const GameState( @@ -31,22 +28,6 @@ void main() { activatedSparkyFires: {}, bonusHistory: [], ), - const GameState( - score: 0, - balls: 1, - activatedBonusLetters: [], - activatedDashNests: {}, - activatedSparkyFires: {}, - bonusHistory: [], - ), - const GameState( - score: 0, - balls: 0, - activatedBonusLetters: [], - activatedDashNests: {}, - activatedSparkyFires: {}, - bonusHistory: [], - ), ], ); }); @@ -252,7 +233,7 @@ void main() { ), GameState( score: 0, - balls: 3, + balls: 4, activatedBonusLetters: [], activatedDashNests: {}, activatedSparkyFires: {}, diff --git a/test/game/components/controlled_ball_test.dart b/test/game/components/controlled_ball_test.dart index 05056484..53847b3c 100644 --- a/test/game/components/controlled_ball_test.dart +++ b/test/game/components/controlled_ball_test.dart @@ -13,42 +13,12 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(EmptyPinballGameTest.new); - group('BonusBallController', () { - late Ball ball; - - setUp(() { - ball = Ball(baseColor: const Color(0xFF00FFFF)); - }); - - test('can be instantiated', () { - expect( - BonusBallController(ball), - isA(), - ); - }); - - flameTester.test( - 'lost removes ball', - (game) async { - await game.add(ball); - final controller = BonusBallController(ball); - await ball.ensureAdd(controller); - - controller.lost(); - await game.ready(); - - expect(game.contains(ball), isFalse); - }, - ); - }); - - group('LaunchedBallController', () { + group('BallController', () { test('can be instantiated', () { expect( - LaunchedBallController(MockBall()), - isA(), + BallController(MockBall()), + isA(), ); }); @@ -74,7 +44,7 @@ void main() { flameBlocTester.testGameWidget( 'lost adds BallLost to GameBloc', setUp: (game, tester) async { - final controller = LaunchedBallController(ball); + final controller = BallController(ball); await ball.add(controller); await game.ensureAdd(ball); @@ -84,114 +54,6 @@ void main() { verify(() => gameBloc.add(const BallLost())).called(1); }, ); - - group('listenWhen', () { - flameBlocTester.testGameWidget( - 'listens when a ball has been lost', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - - await ball.add(controller); - await game.ensureAdd(ball); - }, - verify: (game, tester) async { - final controller = - game.descendants().whereType().first; - - final previousState = MockGameState(); - final newState = MockGameState(); - when(() => previousState.balls).thenReturn(3); - when(() => newState.balls).thenReturn(2); - - expect(controller.listenWhen(previousState, newState), isTrue); - }, - ); - - flameBlocTester.testGameWidget( - 'does not listen when a ball has not been lost', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - - await ball.add(controller); - await game.ensureAdd(ball); - }, - verify: (game, tester) async { - final controller = - game.descendants().whereType().first; - - final previousState = MockGameState(); - final newState = MockGameState(); - when(() => previousState.balls).thenReturn(3); - when(() => newState.balls).thenReturn(3); - - expect(controller.listenWhen(previousState, newState), isFalse); - }, - ); - }); - - group('onNewState', () { - flameBlocTester.testGameWidget( - 'removes ball', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - await ball.add(controller); - await game.ensureAdd(ball); - - final state = MockGameState(); - when(() => state.balls).thenReturn(1); - controller.onNewState(state); - await game.ready(); - }, - verify: (game, tester) async { - expect(game.contains(ball), isFalse); - }, - ); - - flameBlocTester.testGameWidget( - 'spawns a new ball when the ball is not the last one', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - await ball.add(controller); - await game.ensureAdd(ball); - - final state = MockGameState(); - when(() => state.balls).thenReturn(1); - - final previousBalls = game.descendants().whereType().toList(); - controller.onNewState(state); - await game.ready(); - - final currentBalls = game.descendants().whereType().toList(); - - expect(currentBalls.contains(ball), isFalse); - expect(currentBalls.length, equals(previousBalls.length)); - }, - ); - - flameBlocTester.testGameWidget( - 'does not spawn a new ball is the last one', - setUp: (game, tester) async { - final controller = LaunchedBallController(ball); - await ball.add(controller); - await game.ensureAdd(ball); - - final state = MockGameState(); - when(() => state.balls).thenReturn(0); - - final previousBalls = game.descendants().whereType().toList(); - controller.onNewState(state); - await game.ready(); - - final currentBalls = game.descendants().whereType(); - - expect(currentBalls.contains(ball), isFalse); - expect( - currentBalls.length, - equals((previousBalls..remove(ball)).length), - ); - }, - ); - }); }); }); } diff --git a/test/game/components/flutter_forest_test.dart b/test/game/components/flutter_forest_test.dart index 0927974c..10727ffb 100644 --- a/test/game/components/flutter_forest_test.dart +++ b/test/game/components/flutter_forest_test.dart @@ -1,7 +1,6 @@ // ignore_for_file: cascade_invocations import 'package:bloc_test/bloc_test.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -11,18 +10,6 @@ import 'package:pinball_components/pinball_components.dart'; import '../../helpers/helpers.dart'; -void beginContact(Forge2DGame game, BodyComponent bodyA, BodyComponent bodyB) { - assert( - bodyA.body.fixtures.isNotEmpty && bodyB.body.fixtures.isNotEmpty, - 'Bodies require fixtures to contact each other.', - ); - - final fixtureA = bodyA.body.fixtures.first; - final fixtureB = bodyB.body.fixtures.first; - final contact = Contact.init(fixtureA, 0, fixtureB, 0); - game.world.contactManager.contactListener?.beginContact(contact); -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); final flameTester = FlameTester(EmptyPinballGameTest.new); @@ -92,7 +79,7 @@ void main() { ); flameBlocTester.testGameWidget( - 'listens when a Bonus.dashNest is added', + 'listens when a Bonus.dashNest and a bonusBall is added', verify: (game, tester) async { final flutterForest = FlutterForest(); @@ -104,6 +91,7 @@ void main() { activatedSparkyFires: {}, bonusHistory: [GameBonus.dashNest], ); + expect( flutterForest.controller .listenWhen(const GameState.initial(), state), diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart index 18c7ea5b..f8e7483c 100644 --- a/test/game/components/wall_test.dart +++ b/test/game/components/wall_test.dart @@ -3,40 +3,15 @@ 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/game/game.dart'; import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(Forge2DGame.new); + final flameTester = FlameTester(EmptyPinballGameTest.new); group('Wall', () { - group('BottomWallBallContactCallback', () { - test( - 'removes the ball on begin contact when the wall is a bottom one', - () { - final wall = MockBottomWall(); - final ballController = MockBallController(); - final ball = MockBall(); - final componentSet = MockComponentSet(); - - when(() => componentSet.whereType()) - .thenReturn([ballController]); - when(() => ball.children).thenReturn(componentSet); - - BottomWallBallContactCallback() - // Remove once https://github.com/flame-engine/flame/pull/1415 - // is merged - ..end(MockBall(), MockBottomWall(), MockContact()) - ..begin(ball, wall, MockContact()); - - verify(ballController.lost).called(1); - }, - ); - }); - flameTester.test( 'loads correctly', (game) async { @@ -123,4 +98,67 @@ void main() { ); }); }); + + group( + 'BottomWall', + () { + group('removes ball on contact', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = GameBloc(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballGameTest.new, + blocBuilder: () => gameBloc, + ); + + flameBlocTester.testGameWidget( + 'when ball is launch', + setUp: (game, tester) async { + final ball = ControlledBall.launch(theme: game.theme); + final wall = BottomWall(); + await game.ensureAddAll([ball, wall]); + game.addContactCallback(BottomWallBallContactCallback()); + + beginContact(game, ball, wall); + await game.ready(); + + expect(game.contains(ball), isFalse); + }, + ); + + flameBlocTester.testGameWidget( + 'when ball is bonus', + setUp: (game, tester) async { + final ball = ControlledBall.bonus(theme: game.theme); + final wall = BottomWall(); + await game.ensureAddAll([ball, wall]); + game.addContactCallback(BottomWallBallContactCallback()); + + beginContact(game, ball, wall); + await game.ready(); + + expect(game.contains(ball), isFalse); + }, + ); + + flameTester.test( + 'when ball is debug', + (game) async { + final ball = ControlledBall.debug(); + final wall = BottomWall(); + await game.ensureAddAll([ball, wall]); + game.addContactCallback(BottomWallBallContactCallback()); + + beginContact(game, ball, wall); + await game.ready(); + + expect(game.contains(ball), isFalse); + }, + ); + }); + }, + ); } diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index f418bad0..d83bb396 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: cascade_invocations import 'package:flame/components.dart'; +import 'package:flame/game.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -10,11 +11,11 @@ import 'package:pinball_components/pinball_components.dart'; import '../helpers/helpers.dart'; void main() { - group('PinballGame', () { - TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.new); - final debugModeFlameTester = FlameTester(DebugPinballGameTest.new); + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.new); + final debugModeFlameTester = FlameTester(DebugPinballGameTest.new); + group('PinballGame', () { // TODO(alestiago): test if [PinballGame] registers // [BallScorePointsCallback] once the following issue is resolved: // https://github.com/flame-engine/flame/issues/1416 @@ -60,8 +61,106 @@ void main() { equals(1), ); }); + + group('controller', () { + // TODO(alestiago): Write test to be controller agnostic. + group('listenWhen', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = GameBloc(); + }); + + final flameBlocTester = FlameBlocTester( + gameBuilder: EmptyPinballGameTest.new, + blocBuilder: () => gameBloc, + ); + + flameBlocTester.testGameWidget( + 'listens when all balls are lost and there are more than 0 balls', + setUp: (game, tester) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(2); + game.descendants().whereType().forEach( + (ball) => ball.controller.lost(), + ); + await game.ready(); + + expect( + game.controller.listenWhen(MockGameState(), newState), + isTrue, + ); + }, + ); + + flameTester.test( + "doesn't listen when some balls are left", + (game) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(1); + + expect( + game.descendants().whereType().length, + greaterThan(0), + ); + expect( + game.controller.listenWhen(MockGameState(), newState), + isFalse, + ); + }, + ); + + flameBlocTester.test( + "doesn't listen when no balls left", + (game) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(0); + + game.descendants().whereType().forEach( + (ball) => ball.controller.lost(), + ); + await game.ready(); + + expect( + game.descendants().whereType().isEmpty, + isTrue, + ); + expect( + game.controller.listenWhen(MockGameState(), newState), + isFalse, + ); + }, + ); + }); + + group( + 'onNewState', + () { + flameTester.test( + 'spawns a ball', + (game) async { + await game.ready(); + final previousBalls = + game.descendants().whereType().toList(); + + game.controller.onNewState(MockGameState()); + await game.ready(); + final currentBalls = + game.descendants().whereType().toList(); + + expect( + currentBalls.length, + equals(previousBalls.length + 1), + ); + }, + ); + }, + ); + }); }); + }); + group('DebugPinballGame', () { debugModeFlameTester.test('adds a ball on tap up', (game) async { await game.ready(); @@ -71,12 +170,46 @@ void main() { final tapUpEvent = MockTapUpInfo(); when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); + final previousBalls = game.descendants().whereType().toList(); + game.onTapUp(tapUpEvent); await game.ready(); expect( game.children.whereType().length, - equals(1), + equals(previousBalls.length + 1), + ); + }); + + group('controller', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = GameBloc(); + }); + + final debugModeFlameBlocTester = + FlameBlocTester( + gameBuilder: DebugPinballGameTest.new, + blocBuilder: () => gameBloc, + ); + + debugModeFlameBlocTester.testGameWidget( + 'ignores debug balls', + setUp: (game, tester) async { + final newState = MockGameState(); + when(() => newState.balls).thenReturn(1); + + await game.ready(); + game.children.removeWhere((component) => component is Ball); + await game.ready(); + await game.ensureAdd(ControlledBall.debug()); + + expect( + game.controller.listenWhen(MockGameState(), newState), + isTrue, + ); + }, ); }); }); diff --git a/test/helpers/extensions.dart b/test/helpers/extensions.dart index 4731eec4..8e054fe0 100644 --- a/test/helpers/extensions.dart +++ b/test/helpers/extensions.dart @@ -1,3 +1,5 @@ +// ignore_for_file: must_call_super + import 'package:pinball/game/game.dart'; import 'package:pinball_theme/pinball_theme.dart'; diff --git a/test/helpers/forge2d.dart b/test/helpers/forge2d.dart new file mode 100644 index 00000000..f000d404 --- /dev/null +++ b/test/helpers/forge2d.dart @@ -0,0 +1,13 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; + +void beginContact(Forge2DGame game, BodyComponent bodyA, BodyComponent bodyB) { + assert( + bodyA.body.fixtures.isNotEmpty && bodyB.body.fixtures.isNotEmpty, + 'Bodies require fixtures to contact each other.', + ); + + final fixtureA = bodyA.body.fixtures.first; + final fixtureB = bodyB.body.fixtures.first; + final contact = Contact.init(fixtureA, 0, fixtureB, 0); + game.world.contactManager.contactListener?.beginContact(contact); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index d9dc2a17..4b6c29f1 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -7,6 +7,7 @@ export 'builders.dart'; export 'extensions.dart'; export 'fakes.dart'; +export 'forge2d.dart'; export 'key_testers.dart'; export 'mocks.dart'; export 'navigator.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index c0dec5f5..748b48f3 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -21,6 +21,8 @@ class MockBody extends Mock implements Body {} class MockBall extends Mock implements Ball {} +class MockControlledBall extends Mock implements ControlledBall {} + class MockBallController extends Mock implements BallController {} class MockContact extends Mock implements Contact {}