From 68671876594797232abd6b6731efcc9393e1d7b2 Mon Sep 17 00:00:00 2001 From: Rui Miguel Alonso Date: Fri, 18 Mar 2022 14:44:33 +0100 Subject: [PATCH] feat: crossing upper ramps (#40) * feat: added generic area and area callback for ramps crossing * feat: added jetpack ramp (blue one) and own area, contact callback and maskbits * feat: added sparky ramp (yellow one) and own area, contact callback and maskbits * feat: included ramp components * feat: added maskbits to ball for collisions * feat: added paths to pinball game * feat: added maskbits to paths * fix: fixed collisions of a ball that only touch path entrance but doesn't get into * fix: analysis warnings * feat: ball default maskbits * chore: refactor some names and vars * test: tests for ramps and callbacks, and coverage * test: pinball game check ramps are added * test: tests for ramps check childrens * test: fixing tests for ramps * test: fix tests * chore: increase sparky angle * fix: placed plunge aligned with straight launcher path * fix: fixed maskBits change for ball on crossing ramps and tests coverage * doc: public member api docs * chore: placed launcher ramp * test: moved mock from crossing ramps to helpers file * fix: build and dep where broken by forge2d/position_body_component * Update lib/game/components/crossing_ramp.dart Co-authored-by: Erick * Update lib/game/components/jetpack_ramp.dart Co-authored-by: Erick * Update test/game/components/crossing_ramp_test.dart Co-authored-by: Erick * Update test/game/components/jetpack_ramp_test.dart Co-authored-by: Erick * Update test/game/components/sparky_ramp_test.dart Co-authored-by: Erick * Update lib/game/components/jetpack_ramp.dart Co-authored-by: Erick * chore: fixed formatting * chore: removed coverage tool * Update lib/game/components/crossing_ramp.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * Update test/game/components/ball_test.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * Update lib/game/components/crossing_ramp.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * Update lib/game/components/jetpack_ramp.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * Update test/game/components/crossing_ramp_test.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * Update test/game/components/pathway_test.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * Update test/game/pinball_game_test.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * Update test/game/components/pathway_test.dart Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * test: fix tests and groups * chore: ramp area name changed to opening * refactor: ball with mixin Layer for mask collisions * chore: avoid foreach in a function literal * refactor: hide maskbits and manage only with layer param * chore: formatting file * refactor: changed name for ramp area * refactor: sparky+launcher into one path * doc: doc layer for ball * refactor: sparky to launcher * feat: allow jetpack ramp to be over the board * feat: refactor to allow jetpack ramp to be above board and launcher ramp * test: coverage * fix: fixed conflict with merge Component position * chore: analysis fixes * chore: doc and comments * refactor: initial position to ramps and cleaned ramp callbacks * refactor: improved ramp contact callback * refactor: ball layer and ramp addAll components * refactor: create fixtures for pathways and opening improved * refactor: placed ramps on pinball game * refactor: splitted layer from rampopening * refactor: rampopening with layered mixin * test: fixed all changes with tests * test: fixed tests after Layer mixin changes * chore: refactor names, test and doc * chore: review docs and names * fix: fixed tests and bug with initialposition collision * chore: analysis error * fix: fixed collision end from ramps * test: coverage * chore: fixed spaces between methods and other comments from pr * chore: remove unnecessary layer set on Layered * fix: removed unrelated files from pr * chore: removed unused import * refactor: ballsInside private and removed from tests * chore: todo comment * chore: removed unused import * chore: removed podfile * doc: changed Layered doc * doc: changed Layered doc * Update test/game/components/ramp_opening_test.dart Co-authored-by: Alejandro Santiago * Update lib/game/components/layer.dart Co-authored-by: Alejandro Santiago * docs: improved punctuation Co-authored-by: Erick Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Co-authored-by: Alejandro Santiago --- lib/game/components/ball.dart | 11 +- lib/game/components/baseboard.dart | 10 +- lib/game/components/components.dart | 4 + lib/game/components/flipper.dart | 10 +- lib/game/components/jetpack_ramp.dart | 87 +++++++ lib/game/components/launcher_ramp.dart | 90 +++++++ lib/game/components/layer.dart | 86 +++++++ lib/game/components/pathway.dart | 22 +- lib/game/components/ramp_opening.dart | 105 ++++++++ lib/game/components/sling_shot.dart | 8 +- lib/game/pinball_game.dart | 24 +- test/game/components/ball_test.dart | 12 + test/game/components/jetpack_ramp_test.dart | 63 +++++ test/game/components/launcher_ramp_test.dart | 74 ++++++ test/game/components/layer_test.dart | 144 +++++++++++ test/game/components/pathway_test.dart | 48 ++-- test/game/components/ramp_opening_test.dart | 249 +++++++++++++++++++ test/game/pinball_game_test.dart | 26 ++ test/helpers/mocks.dart | 7 + 19 files changed, 1031 insertions(+), 49 deletions(-) create mode 100644 lib/game/components/jetpack_ramp.dart create mode 100644 lib/game/components/launcher_ramp.dart create mode 100644 lib/game/components/layer.dart create mode 100644 lib/game/components/ramp_opening.dart create mode 100644 test/game/components/jetpack_ramp_test.dart create mode 100644 test/game/components/launcher_ramp_test.dart create mode 100644 test/game/components/layer_test.dart create mode 100644 test/game/components/ramp_opening_test.dart diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index 36ed8929..131e7e10 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -6,9 +6,16 @@ import 'package:pinball/game/game.dart'; /// A solid, [BodyType.dynamic] sphere that rolls and bounces along the /// [PinballGame]. /// {@endtemplate} -class Ball extends BodyComponent with InitialPosition { +class Ball extends BodyComponent with InitialPosition, Layered { /// {@macro ball} - Ball(); + Ball() { + // TODO(ruimiguel): while developing Ball can be launched by clicking mouse, + // and default layer is Layer.all. But on final game Ball will be always be + // be launched from Plunger and LauncherRamp will modify it to Layer.board. + // We need to see what happens if Ball appears from other place like nest + // bumper, it will need to explicit change layer to Layer.board then. + layer = Layer.board; + } /// The size of the [Ball] final Vector2 size = Vector2.all(2); diff --git a/lib/game/components/baseboard.dart b/lib/game/components/baseboard.dart index 62c9210c..48d38497 100644 --- a/lib/game/components/baseboard.dart +++ b/lib/game/components/baseboard.dart @@ -22,7 +22,7 @@ class Baseboard extends BodyComponent with InitialPosition { final BoardSide _side; List _createFixtureDefs() { - final fixtures = []; + final fixturesDef = []; final circleShape1 = CircleShape()..radius = Baseboard.height / 2; circleShape1.position.setValues( @@ -30,7 +30,7 @@ class Baseboard extends BodyComponent with InitialPosition { 0, ); final circle1FixtureDef = FixtureDef(circleShape1); - fixtures.add(circle1FixtureDef); + fixturesDef.add(circle1FixtureDef); final circleShape2 = CircleShape()..radius = Baseboard.height / 2; circleShape2.position.setValues( @@ -38,7 +38,7 @@ class Baseboard extends BodyComponent with InitialPosition { 0, ); final circle2FixtureDef = FixtureDef(circleShape2); - fixtures.add(circle2FixtureDef); + fixturesDef.add(circle2FixtureDef); final rectangle = PolygonShape() ..setAsBoxXY( @@ -46,9 +46,9 @@ class Baseboard extends BodyComponent with InitialPosition { Baseboard.height / 2, ); final rectangleFixtureDef = FixtureDef(rectangle); - fixtures.add(rectangleFixtureDef); + fixturesDef.add(rectangleFixtureDef); - return fixtures; + return fixturesDef; } @override diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 86fa3845..ea299c7b 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -5,9 +5,13 @@ export 'board_side.dart'; export 'bonus_word.dart'; export 'flipper.dart'; export 'initial_position.dart'; +export 'jetpack_ramp.dart'; export 'joint_anchor.dart'; +export 'launcher_ramp.dart'; +export 'layer.dart'; export 'pathway.dart'; export 'plunger.dart'; +export 'ramp_opening.dart'; export 'round_bumper.dart'; export 'score_points.dart'; export 'sling_shot.dart'; diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart index 3d77a57b..1bdf8d0f 100644 --- a/lib/game/components/flipper.dart +++ b/lib/game/components/flipper.dart @@ -131,7 +131,7 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { } List _createFixtureDefs() { - final fixtures = []; + final fixturesDef = []; final isLeft = side.isLeft; final bigCircleShape = CircleShape()..radius = height / 2; @@ -142,7 +142,7 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { 0, ); final bigCircleFixtureDef = FixtureDef(bigCircleShape); - fixtures.add(bigCircleFixtureDef); + fixturesDef.add(bigCircleFixtureDef); final smallCircleShape = CircleShape()..radius = bigCircleShape.radius / 2; smallCircleShape.position.setValues( @@ -152,7 +152,7 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { 0, ); final smallCircleFixtureDef = FixtureDef(smallCircleShape); - fixtures.add(smallCircleFixtureDef); + fixturesDef.add(smallCircleFixtureDef); final trapeziumVertices = isLeft ? [ @@ -171,9 +171,9 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { final trapeziumFixtureDef = FixtureDef(trapezium) ..density = 50.0 // TODO(alestiago): Use a proper density. ..friction = .1; // TODO(alestiago): Use a proper friction. - fixtures.add(trapeziumFixtureDef); + fixturesDef.add(trapeziumFixtureDef); - return fixtures; + return fixturesDef; } @override diff --git a/lib/game/components/jetpack_ramp.dart b/lib/game/components/jetpack_ramp.dart new file mode 100644 index 00000000..a11ac40c --- /dev/null +++ b/lib/game/components/jetpack_ramp.dart @@ -0,0 +1,87 @@ +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template jetpack_ramp} +/// Represents the upper left blue ramp of the [Board]. +/// {@endtemplate} +class JetpackRamp extends Component with HasGameRef { + /// {@macro jetpack_ramp} + JetpackRamp({ + required this.position, + }); + + /// The position of this [JetpackRamp]. + final Vector2 position; + + @override + Future onLoad() async { + const layer = Layer.jetpack; + + gameRef.addContactCallback( + RampOpeningBallContactCallback<_JetpackRampOpening>(), + ); + + final curvePath = Pathway.arc( + // TODO(ruialonso): Remove color when not needed. + // TODO(ruialonso): Use a bezier curve once control points are defined. + color: const Color.fromARGB(255, 8, 218, 241), + center: position, + width: 80, + radius: 200, + angle: 7 * math.pi / 6, + rotation: -math.pi / 18, + ) + ..initialPosition = position + ..layer = layer; + final leftOpening = _JetpackRampOpening( + rotation: 15 * math.pi / 180, + ) + ..initialPosition = position + Vector2(-27, 21) + ..layer = Layer.opening; + final rightOpening = _JetpackRampOpening( + rotation: -math.pi / 20, + ) + ..initialPosition = position + Vector2(-11.2, 22.5) + ..layer = Layer.opening; + + await addAll([ + curvePath, + leftOpening, + rightOpening, + ]); + } +} + +/// {@template jetpack_ramp_opening} +/// [RampOpening] with [Layer.jetpack] to filter [Ball] collisions +/// inside [JetpackRamp]. +/// {@endtemplate} +class _JetpackRampOpening extends RampOpening { + /// {@macro jetpack_ramp_opening} + _JetpackRampOpening({ + required double rotation, + }) : _rotation = rotation, + super( + pathwayLayer: Layer.jetpack, + orientation: RampOrientation.down, + ); + + final double _rotation; + + // TODO(ruialonso): Avoid magic number 3, should be propotional to + // [JetpackRamp]. + static final Vector2 _size = Vector2(3, .1); + + @override + Shape get shape => PolygonShape() + ..setAsBox( + _size.x, + _size.y, + initialPosition, + _rotation, + ); +} diff --git a/lib/game/components/launcher_ramp.dart b/lib/game/components/launcher_ramp.dart new file mode 100644 index 00000000..21d4d666 --- /dev/null +++ b/lib/game/components/launcher_ramp.dart @@ -0,0 +1,90 @@ +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template launcher_ramp} +/// The yellow left ramp, where the [Ball] goes through when launched from the +/// [Plunger]. +/// {@endtemplate} +class LauncherRamp extends Component with HasGameRef { + /// {@macro launcher_ramp} + LauncherRamp({ + required this.position, + }); + + /// The position of this [LauncherRamp]. + final Vector2 position; + + @override + Future onLoad() async { + const layer = Layer.launcher; + + gameRef.addContactCallback( + RampOpeningBallContactCallback<_LauncherRampOpening>(), + ); + + final straightPath = Pathway.straight( + color: const Color.fromARGB(255, 34, 255, 0), + start: Vector2(0, 0), + end: Vector2(0, 700), + width: 80, + ) + ..initialPosition = position + ..layer = layer; + final curvedPath = Pathway.arc( + color: const Color.fromARGB(255, 251, 255, 0), + center: position + Vector2(-29, -8), + radius: 300, + angle: 10 * math.pi / 9, + width: 80, + ) + ..initialPosition = position + Vector2(-28.8, -6) + ..layer = layer; + final leftOpening = _LauncherRampOpening(rotation: 13 * math.pi / 180) + ..initialPosition = position + Vector2(-72.5, 12) + ..layer = Layer.opening; + final rightOpening = _LauncherRampOpening(rotation: 0) + ..initialPosition = position + Vector2(-46.8, 17) + ..layer = Layer.opening; + + await addAll([ + straightPath, + curvedPath, + leftOpening, + rightOpening, + ]); + } +} + +/// {@template launcher_ramp_opening} +/// [RampOpening] with [Layer.launcher] to filter [Ball]s collisions +/// inside [LauncherRamp]. +/// {@endtemplate} +class _LauncherRampOpening extends RampOpening { + /// {@macro launcher_ramp_opening} + _LauncherRampOpening({ + required double rotation, + }) : _rotation = rotation, + super( + pathwayLayer: Layer.launcher, + orientation: RampOrientation.down, + ); + + final double _rotation; + + // TODO(ruialonso): Avoid magic number 3, should be propotional to + // [JetpackRamp]. + static final Vector2 _size = Vector2(3, .1); + + @override + Shape get shape => PolygonShape() + ..setAsBox( + _size.x, + _size.y, + initialPosition, + _rotation, + ); +} diff --git a/lib/game/components/layer.dart b/lib/game/components/layer.dart new file mode 100644 index 00000000..d5df0698 --- /dev/null +++ b/lib/game/components/layer.dart @@ -0,0 +1,86 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; + +/// {@template layered} +/// Modifies maskBits and categoryBits of all the [BodyComponent]'s [Fixture]s +/// to specify what other [BodyComponent]s it can collide with. +/// +/// [BodyComponent]s with compatible [Layer]s can collide with each other, +/// ignoring others. This compatibility depends on bit masking operation +/// between layers. For more information read: https://en.wikipedia.org/wiki/Mask_(computing). +/// {@endtemplate} +mixin Layered on BodyComponent { + Layer _layer = Layer.all; + + /// {@macro layered} + Layer get layer => _layer; + + set layer(Layer value) { + _layer = value; + if (!isLoaded) { + // TODO(alestiago): Use loaded.whenComplete once provided. + mounted.whenComplete(_applyMaskBits); + } else { + _applyMaskBits(); + } + } + + void _applyMaskBits() { + for (final fixture in body.fixtures) { + fixture + ..filterData.categoryBits = layer.maskBits + ..filterData.maskBits = layer.maskBits; + } + } +} + +/// The [Layer]s a [BodyComponent] can be in. +/// +/// Each [Layer] is associated with a maskBits value to define possible +/// collisions within that plane. +/// +/// Usually used with [Layered]. +enum Layer { + /// Collide with all elements. + all, + + /// Collide only with board elements (the ground level). + board, + + /// Collide only with ramps opening elements. + opening, + + /// Collide only with Jetpack group elements. + jetpack, + + /// Collide only with Launcher group elements. + launcher, +} + +/// {@template layer_mask_bits} +/// Specifies the maskBits of each [Layer]. +/// +/// Used by [Layered] to specify what other [BodyComponent]s it can collide +/// +/// Note: the maximum value for maskBits is 2^16. +/// {@endtemplate} +@visibleForTesting +extension LayerMaskBits on Layer { + /// {@macro layer_mask_bits} + @visibleForTesting + int get maskBits { + // TODO(ruialonso): test bit groups once final design is implemented. + switch (this) { + case Layer.all: + return 0xFFFF; + case Layer.board: + return 0x0001; + case Layer.opening: + return 0x0007; + case Layer.jetpack: + return 0x0002; + case Layer.launcher: + return 0x0005; + } + } +} diff --git a/lib/game/components/pathway.dart b/lib/game/components/pathway.dart index e4a6f1f5..414442d3 100644 --- a/lib/game/components/pathway.dart +++ b/lib/game/components/pathway.dart @@ -9,7 +9,7 @@ import 'package:pinball/game/game.dart'; /// /// [BodyComponent]s such as a Ball can collide and move along a [Pathway]. /// {@endtemplate} -class Pathway extends BodyComponent with InitialPosition { +class Pathway extends BodyComponent with InitialPosition, Layered { Pathway._({ // TODO(ruialonso): remove color when assets added. Color? color, @@ -146,20 +146,26 @@ class Pathway extends BodyComponent with InitialPosition { final List> _paths; - @override - Body createBody() { - final bodyDef = BodyDef()..position = initialPosition; - final body = world.createBody(bodyDef); + List _createFixtureDefs() { + final fixturesDef = []; + for (final path in _paths) { final chain = ChainShape() ..createChain( path.map(gameRef.screenToWorld).toList(), ); - final fixtureDef = FixtureDef(chain); - - body.createFixture(fixtureDef); + fixturesDef.add(FixtureDef(chain)); } + return fixturesDef; + } + + @override + Body createBody() { + final bodyDef = BodyDef()..position = initialPosition; + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + return body; } } diff --git a/lib/game/components/ramp_opening.dart b/lib/game/components/ramp_opening.dart new file mode 100644 index 00000000..4ff8f8c9 --- /dev/null +++ b/lib/game/components/ramp_opening.dart @@ -0,0 +1,105 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template ramp_orientation} +/// Determines if a ramp is facing [up] or [down] on the [Board]. +/// {@endtemplate} +enum RampOrientation { + /// Facing up on the [Board]. + up, + + /// Facing down on the [Board]. + down, +} + +/// {@template ramp_opening} +/// [BodyComponent] located at the entrance and exit of a ramp. +/// +/// [RampOpeningBallContactCallback] detects when a [Ball] passes +/// through this opening. +/// +/// By default the base [layer] is set to [Layer.board]. +/// {@endtemplate} +// TODO(ruialonso): Consider renaming the class. +abstract class RampOpening extends BodyComponent with InitialPosition, Layered { + /// {@macro ramp_opening} + RampOpening({ + required Layer pathwayLayer, + required this.orientation, + }) : _pathwayLayer = pathwayLayer { + layer = Layer.board; + } + final Layer _pathwayLayer; + + /// Mask of category bits for collision inside [Pathway]. + Layer get pathwayLayer => _pathwayLayer; + + /// The [Shape] of the [RampOpening]. + Shape get shape; + + /// {@macro ramp_orientation} + // TODO(ruimiguel): Try to remove the need of [RampOrientation] for collision + // calculations. + final RampOrientation orientation; + + @override + Body createBody() { + final fixtureDef = FixtureDef(shape)..isSensor = true; + + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..type = BodyType.static; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} + +/// {@template ramp_opening_ball_contact_callback} +/// Detects when a [Ball] enters or exits a [Pathway] ramp through a +/// [RampOpening]. +/// +/// Modifies [Ball]'s [Layer] accordingly depending on whether the [Ball] is +/// outside or inside a ramp. +/// {@endtemplate} +class RampOpeningBallContactCallback + extends ContactCallback { + /// [Ball]s currently inside the ramp. + final _ballsInside = {}; + + @override + void begin(Ball ball, Opening opening, Contact _) { + Layer layer; + + if (!_ballsInside.contains(ball)) { + layer = opening.pathwayLayer; + _ballsInside.add(ball); + ball.layer = layer; + } else { + _ballsInside.remove(ball); + } + } + + @override + void end(Ball ball, Opening opening, Contact _) { + if (!_ballsInside.contains(ball)) { + ball.layer = Layer.board; + } else { + // TODO(ruimiguel): change this code. Check what happens with ball that + // slightly touch Opening and goes out again. With InitialPosition change + // now doesn't work position.y comparison + final isBallOutsideOpening = + (opening.orientation == RampOrientation.down && + ball.body.linearVelocity.y < 0) || + (opening.orientation == RampOrientation.up && + ball.body.linearVelocity.y > 0); + + if (isBallOutsideOpening) { + ball.layer = Layer.board; + _ballsInside.remove(ball); + } + } + } +} diff --git a/lib/game/components/sling_shot.dart b/lib/game/components/sling_shot.dart index 61d28c29..a39d130e 100644 --- a/lib/game/components/sling_shot.dart +++ b/lib/game/components/sling_shot.dart @@ -34,7 +34,7 @@ class SlingShot extends BodyComponent with InitialPosition { static final Vector2 size = Vector2(6, 8); List _createFixtureDefs() { - final fixtures = []; + final fixturesDef = []; // TODO(alestiago): This magic number can be deduced by specifying the // angle and using polar coordinate system to place the bottom right @@ -65,7 +65,7 @@ class SlingShot extends BodyComponent with InitialPosition { final triangle = PolygonShape()..set(triangleVertices); final triangleFixtureDef = FixtureDef(triangle)..friction = 0; - fixtures.add(triangleFixtureDef); + fixturesDef.add(triangleFixtureDef); final kicker = EdgeShape() ..set( @@ -76,9 +76,9 @@ class SlingShot extends BodyComponent with InitialPosition { final kickerFixtureDef = FixtureDef(kicker) ..restitution = 10.0 ..friction = 0; - fixtures.add(kickerFixtureDef); + fixturesDef.add(kickerFixtureDef); - return fixtures; + return fixturesDef; } @override diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index c0017b33..18835c98 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -27,6 +27,7 @@ class PinballGame extends Forge2DGame await _addGameBoundaries(); unawaited(_addPlunger()); + unawaited(_addPaths()); // Corner wall above plunger so the ball deflects into the rest of the // board. @@ -96,13 +97,34 @@ class PinballGame extends Forge2DGame createBoundaries(this).forEach(add); } + Future _addPaths() async { + final jetpackRamp = JetpackRamp( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 - 150, + camera.viewport.effectiveSize.y / 2 - 250, + ), + ), + ); + final launcherRamp = LauncherRamp( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 + 400, + camera.viewport.effectiveSize.y / 2 - 330, + ), + ), + ); + + await addAll([jetpackRamp, launcherRamp]); + } + Future _addPlunger() async { plunger = Plunger( compressionDistance: camera.viewport.effectiveSize.y / 12, ); plunger.initialPosition = screenToWorld( Vector2( - camera.viewport.effectiveSize.x / 1.035, + camera.viewport.effectiveSize.x / 2 + 450, camera.viewport.effectiveSize.y - plunger.compressionDistance, ), ); diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index 829ca875..3d4b5557 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -70,6 +70,18 @@ void main() { expect(fixture.shape.radius, equals(1)); }, ); + + flameTester.test( + 'has Layer.all as default filter maskBits', + (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + await ball.mounted; + + final fixture = ball.body.fixtures[0]; + expect(fixture.filterData.maskBits, equals(Layer.board.maskBits)); + }, + ); }); group('lost', () { diff --git a/test/game/components/jetpack_ramp_test.dart b/test/game/components/jetpack_ramp_test.dart new file mode 100644 index 00000000..b88bdf23 --- /dev/null +++ b/test/game/components/jetpack_ramp_test.dart @@ -0,0 +1,63 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.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/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + group('JetpackRamp', () { + flameTester.test( + 'loads correctly', + (game) async { + final ramp = JetpackRamp( + position: Vector2.zero(), + ); + await game.ready(); + await game.ensureAdd(ramp); + + expect(game.contains(ramp), isTrue); + }, + ); + + group('children', () { + flameTester.test( + 'has only one Pathway.arc', + (game) async { + final ramp = JetpackRamp( + position: Vector2.zero(), + ); + await game.ready(); + await game.ensureAdd(ramp); + + expect( + () => ramp.children.singleWhere( + (component) => component is Pathway, + ), + returnsNormally, + ); + }, + ); + + flameTester.test( + 'has a two RampOpenings for the ramp', + (game) async { + final ramp = JetpackRamp( + position: Vector2.zero(), + ); + await game.ready(); + await game.ensureAdd(ramp); + + final rampAreas = ramp.children.whereType(); + expect(rampAreas.length, 2); + }, + ); + }); + }); +} diff --git a/test/game/components/launcher_ramp_test.dart b/test/game/components/launcher_ramp_test.dart new file mode 100644 index 00000000..e9350f05 --- /dev/null +++ b/test/game/components/launcher_ramp_test.dart @@ -0,0 +1,74 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.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/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + group('LauncherRamp', () { + flameTester.test( + 'loads correctly', + (game) async { + final ramp = LauncherRamp( + position: Vector2.zero(), + ); + await game.ready(); + await game.ensureAdd(ramp); + + expect(game.contains(ramp), isTrue); + }, + ); + + group('constructor', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final ramp = LauncherRamp( + position: position, + ); + await game.ensureAdd(ramp); + + expect(ramp.position, equals(position)); + }, + ); + }); + + group('children', () { + flameTester.test( + 'has two Pathway', + (game) async { + final ramp = LauncherRamp( + position: Vector2.zero(), + ); + await game.ready(); + await game.ensureAdd(ramp); + + final pathways = ramp.children.whereType().toList(); + expect(pathways.length, 2); + }, + ); + + flameTester.test( + 'has a two RampOpenings for the ramp', + (game) async { + final ramp = LauncherRamp( + position: Vector2.zero(), + ); + await game.ready(); + await game.ensureAdd(ramp); + + final rampAreas = ramp.children.whereType().toList(); + expect(rampAreas.length, 2); + }, + ); + }); + }); +} diff --git a/test/game/components/layer_test.dart b/test/game/components/layer_test.dart new file mode 100644 index 00000000..3aacdb49 --- /dev/null +++ b/test/game/components/layer_test.dart @@ -0,0 +1,144 @@ +// ignore_for_file: cascade_invocations +import 'dart:math' as math; + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +class TestBodyComponent extends BodyComponent with Layered { + @override + Body createBody() { + final fixtureDef = FixtureDef(CircleShape()); + return world.createBody(BodyDef())..createFixture(fixtureDef); + } +} + +void main() { + final flameTester = FlameTester(Forge2DGame.new); + + group('Layered', () { + void _expectLayerOnFixtures({ + required List fixtures, + required Layer layer, + }) { + expect(fixtures.length, greaterThan(0)); + for (final fixture in fixtures) { + expect( + fixture.filterData.categoryBits, + equals(layer.maskBits), + ); + expect(fixture.filterData.maskBits, equals(layer.maskBits)); + } + } + + flameTester.test('TestBodyComponent has fixtures', (game) async { + final component = TestBodyComponent(); + await game.ensureAdd(component); + }); + + test('correctly sets and gets', () { + final component = TestBodyComponent()..layer = Layer.jetpack; + expect(component.layer, Layer.jetpack); + }); + + flameTester.test( + 'layers correctly before being loaded', + (game) async { + const expectedLayer = Layer.jetpack; + final component = TestBodyComponent()..layer = expectedLayer; + await game.ensureAdd(component); + // TODO(alestiago): modify once component.loaded is available. + await component.mounted; + + _expectLayerOnFixtures( + fixtures: component.body.fixtures, + layer: expectedLayer, + ); + }, + ); + + flameTester.test( + 'layers correctly before being loaded ' + 'when multiple different sets', + (game) async { + const expectedLayer = Layer.launcher; + final component = TestBodyComponent()..layer = Layer.jetpack; + + expect(component.layer, isNot(equals(expectedLayer))); + component.layer = expectedLayer; + + await game.ensureAdd(component); + // TODO(alestiago): modify once component.loaded is available. + await component.mounted; + + _expectLayerOnFixtures( + fixtures: component.body.fixtures, + layer: expectedLayer, + ); + }, + ); + + flameTester.test( + 'layers correctly after being loaded', + (game) async { + const expectedLayer = Layer.jetpack; + final component = TestBodyComponent(); + await game.ensureAdd(component); + component.layer = expectedLayer; + _expectLayerOnFixtures( + fixtures: component.body.fixtures, + layer: expectedLayer, + ); + }, + ); + + flameTester.test( + 'layers correctly after being loaded ' + 'when multiple different sets', + (game) async { + const expectedLayer = Layer.launcher; + final component = TestBodyComponent(); + await game.ensureAdd(component); + + component.layer = Layer.jetpack; + expect(component.layer, isNot(equals(expectedLayer))); + component.layer = expectedLayer; + + _expectLayerOnFixtures( + fixtures: component.body.fixtures, + layer: expectedLayer, + ); + }, + ); + + flameTester.test( + 'defaults to Layer.all ' + 'when no layer is given', + (game) async { + final component = TestBodyComponent(); + await game.ensureAdd(component); + expect(component.layer, equals(Layer.all)); + }, + ); + }); + + group('LayerMaskBits', () { + test('all types are different', () { + for (final layer in Layer.values) { + for (final otherLayer in Layer.values) { + if (layer != otherLayer) { + expect(layer.maskBits, isNot(equals(otherLayer.maskBits))); + } + } + } + }); + + test('all maskBits are smaller than 2^16 ', () { + final maxMaskBitSize = math.pow(2, 16); + for (final layer in Layer.values) { + expect(layer.maskBits, isNot(greaterThan(maxMaskBitSize))); + } + }); + }); +} diff --git a/test/game/components/pathway_test.dart b/test/game/components/pathway_test.dart index 550714cb..03b67c62 100644 --- a/test/game/components/pathway_test.dart +++ b/test/game/components/pathway_test.dart @@ -14,16 +14,31 @@ void main() { const width = 50.0; group('straight', () { + flameTester.test( + 'loads correctly', + (game) async { + final pathway = Pathway.straight( + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + ); + await game.ready(); + await game.ensureAdd(pathway); + + expect(game.contains(pathway), isTrue); + }, + ); + group('color', () { flameTester.test( 'has transparent color by default when no color is specified', (game) async { - await game.ready(); final pathway = Pathway.straight( start: Vector2(10, 10), end: Vector2(20, 20), width: width, ); + await game.ready(); await game.ensureAdd(pathway); expect(game.contains(pathway), isTrue); @@ -38,7 +53,6 @@ void main() { flameTester.test( 'has a color when is specified', (game) async { - await game.ready(); const defaultColor = Colors.blue; final pathway = Pathway.straight( @@ -47,6 +61,7 @@ void main() { end: Vector2(20, 20), width: width, ); + await game.ready(); await game.ensureAdd(pathway); expect(game.contains(pathway), isTrue); @@ -56,31 +71,16 @@ void main() { ); }); - flameTester.test( - 'loads correctly', - (game) async { - await game.ready(); - final pathway = Pathway.straight( - start: Vector2(10, 10), - end: Vector2(20, 20), - width: width, - ); - await game.ensureAdd(pathway); - - expect(game.contains(pathway), isTrue); - }, - ); - group('body', () { flameTester.test( 'is static', (game) async { - await game.ready(); final pathway = Pathway.straight( start: Vector2(10, 10), end: Vector2(20, 20), width: width, ); + await game.ready(); await game.ensureAdd(pathway); expect(pathway.body.bodyType, equals(BodyType.static)); @@ -92,13 +92,13 @@ void main() { flameTester.test( 'has only one ChainShape when singleWall is true', (game) async { - await game.ready(); final pathway = Pathway.straight( start: Vector2(10, 10), end: Vector2(20, 20), width: width, singleWall: true, ); + await game.ready(); await game.ensureAdd(pathway); expect(pathway.body.fixtures.length, 1); @@ -111,12 +111,12 @@ void main() { flameTester.test( 'has two ChainShape when singleWall is false (default)', (game) async { - await game.ready(); final pathway = Pathway.straight( start: Vector2(10, 10), end: Vector2(20, 20), width: width, ); + await game.ready(); await game.ensureAdd(pathway); expect(pathway.body.fixtures.length, 2); @@ -133,13 +133,13 @@ void main() { flameTester.test( 'loads correctly', (game) async { - await game.ready(); final pathway = Pathway.arc( center: Vector2.zero(), width: width, radius: 100, angle: math.pi / 2, ); + await game.ready(); await game.ensureAdd(pathway); expect(game.contains(pathway), isTrue); @@ -150,13 +150,13 @@ void main() { flameTester.test( 'is static', (game) async { - await game.ready(); final pathway = Pathway.arc( center: Vector2.zero(), width: width, radius: 100, angle: math.pi / 2, ); + await game.ready(); await game.ensureAdd(pathway); expect(pathway.body.bodyType, equals(BodyType.static)); @@ -176,11 +176,11 @@ void main() { flameTester.test( 'loads correctly', (game) async { - await game.ready(); final pathway = Pathway.bezierCurve( controlPoints: controlPoints, width: width, ); + await game.ready(); await game.ensureAdd(pathway); expect(game.contains(pathway), isTrue); @@ -191,11 +191,11 @@ void main() { flameTester.test( 'is static', (game) async { - await game.ready(); final pathway = Pathway.bezierCurve( controlPoints: controlPoints, width: width, ); + await game.ready(); await game.ensureAdd(pathway); expect(pathway.body.bodyType, equals(BodyType.static)); diff --git a/test/game/components/ramp_opening_test.dart b/test/game/components/ramp_opening_test.dart new file mode 100644 index 00000000..1876f8ae --- /dev/null +++ b/test/game/components/ramp_opening_test.dart @@ -0,0 +1,249 @@ +// 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:mockingjay/mockingjay.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +class TestRampOpening extends RampOpening { + TestRampOpening({ + required RampOrientation orientation, + required Layer pathwayLayer, + }) : super( + pathwayLayer: pathwayLayer, + orientation: orientation, + ); + + @override + Shape get shape => PolygonShape() + ..set([ + Vector2(0, 0), + Vector2(0, 1), + Vector2(1, 1), + Vector2(1, 0), + ]); +} + +class TestRampOpeningBallContactCallback + extends RampOpeningBallContactCallback { + TestRampOpeningBallContactCallback() : super(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + group('RampOpening', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + flameTester.test( + 'loads correctly', + (game) async { + final ramp = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: Layer.jetpack, + ); + await game.ready(); + await game.ensureAdd(ramp); + + expect(game.contains(ramp), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'is static', + (game) async { + final ramp = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: Layer.jetpack, + ); + await game.ensureAdd(ramp); + + expect(ramp.body.bodyType, equals(BodyType.static)); + }, + ); + + group('first fixture', () { + const pathwayLayer = Layer.jetpack; + const openingLayer = Layer.opening; + + flameTester.test( + 'exists', + (game) async { + final ramp = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: pathwayLayer, + )..layer = openingLayer; + await game.ensureAdd(ramp); + + expect(ramp.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'shape is a polygon', + (game) async { + final ramp = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: pathwayLayer, + )..layer = openingLayer; + await game.ensureAdd(ramp); + + final fixture = ramp.body.fixtures[0]; + expect(fixture.shape.shapeType, equals(ShapeType.polygon)); + }, + ); + + flameTester.test( + 'is sensor', + (game) async { + final ramp = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: pathwayLayer, + )..layer = openingLayer; + await game.ensureAdd(ramp); + + final fixture = ramp.body.fixtures[0]; + expect(fixture.isSensor, isTrue); + }, + ); + }); + }); + }); + + group('RampOpeningBallContactCallback', () { + flameTester.test( + 'changes ball layer ' + 'when a ball enters upwards into a downward ramp opening', + (game) async { + final ball = MockBall(); + final body = MockBody(); + final area = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: Layer.jetpack, + ); + final callback = TestRampOpeningBallContactCallback(); + + when(() => ball.body).thenReturn(body); + when(() => body.position).thenReturn(Vector2.zero()); + when(() => ball.layer).thenReturn(Layer.board); + + await game.ready(); + await game.ensureAdd(area); + + callback.begin(ball, area, MockContact()); + verify(() => ball.layer = area.pathwayLayer).called(1); + }); + + flameTester.test( + 'changes ball layer ' + 'when a ball enters downwards into a upward ramp opening', + (game) async { + final ball = MockBall(); + final body = MockBody(); + final area = TestRampOpening( + orientation: RampOrientation.up, + pathwayLayer: Layer.jetpack, + ); + final callback = TestRampOpeningBallContactCallback(); + + when(() => ball.body).thenReturn(body); + when(() => body.position).thenReturn(Vector2.zero()); + when(() => ball.layer).thenReturn(Layer.board); + + await game.ready(); + await game.ensureAdd(area); + + callback.begin(ball, area, MockContact()); + verify(() => ball.layer = area.pathwayLayer).called(1); + }); + + flameTester.test( + 'changes ball layer ' + 'when a ball exits from a downward oriented ramp', (game) async { + final ball = MockBall(); + final body = MockBody(); + final area = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: Layer.jetpack, + )..initialPosition = Vector2(0, 10); + final callback = TestRampOpeningBallContactCallback(); + + when(() => ball.body).thenReturn(body); + when(() => body.position).thenReturn(Vector2.zero()); + when(() => body.linearVelocity).thenReturn(Vector2(0, -1)); + when(() => ball.layer).thenReturn(Layer.board); + + await game.ready(); + await game.ensureAdd(area); + + callback.begin(ball, area, MockContact()); + verify(() => ball.layer = area.pathwayLayer).called(1); + + callback.end(ball, area, MockContact()); + verify(() => ball.layer = Layer.board); + }); + + flameTester.test( + 'changes ball layer ' + 'when a ball exits from a upward oriented ramp', (game) async { + final ball = MockBall(); + final body = MockBody(); + final area = TestRampOpening( + orientation: RampOrientation.up, + pathwayLayer: Layer.jetpack, + )..initialPosition = Vector2(0, 10); + final callback = TestRampOpeningBallContactCallback(); + + when(() => ball.body).thenReturn(body); + when(() => body.position).thenReturn(Vector2.zero()); + when(() => body.linearVelocity).thenReturn(Vector2(0, 1)); + when(() => ball.layer).thenReturn(Layer.board); + + await game.ready(); + await game.ensureAdd(area); + + callback.begin(ball, area, MockContact()); + verify(() => ball.layer = area.pathwayLayer).called(1); + + callback.end(ball, area, MockContact()); + verify(() => ball.layer = Layer.board); + }); + + flameTester.test( + 'change ball layer from pathwayLayer to Layer.board ' + 'when a ball enters and exits from ramp', (game) async { + final ball = MockBall(); + final body = MockBody(); + final area = TestRampOpening( + orientation: RampOrientation.down, + pathwayLayer: Layer.jetpack, + )..initialPosition = Vector2(0, 10); + final callback = TestRampOpeningBallContactCallback(); + + when(() => ball.body).thenReturn(body); + when(() => body.position).thenReturn(Vector2.zero()); + when(() => body.linearVelocity).thenReturn(Vector2(0, 1)); + when(() => ball.layer).thenReturn(Layer.board); + + await game.ready(); + await game.ensureAdd(area); + + callback.begin(ball, area, MockContact()); + verify(() => ball.layer = area.pathwayLayer).called(1); + + callback.end(ball, area, MockContact()); + verifyNever(() => ball.layer = Layer.board); + + callback.begin(ball, area, MockContact()); + verifyNever(() => ball.layer = area.pathwayLayer); + + callback.end(ball, area, MockContact()); + verify(() => ball.layer = Layer.board); + }); + }); +} diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 6b3d5a5f..c145e2e4 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -63,6 +63,32 @@ void main() { }); }); + group('Paths', () { + flameTester.test( + 'has only one JetpackRamp', + (game) async { + await game.ready(); + + expect( + () => game.children.singleWhere( + (component) => component is JetpackRamp, + ), + returnsNormally, + ); + }, + ); + + flameTester.test( + 'has only one LauncherRamp', + (game) async { + await game.ready(); + + final rampAreas = game.children.whereType().toList(); + expect(rampAreas.length, 1); + }, + ); + }); + debugModeFlameTester.test('adds a ball on tap up', (game) async { await game.ready(); diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 80820c1b..cace878a 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -12,10 +12,17 @@ class MockWall extends Mock implements Wall {} class MockBottomWall extends Mock implements BottomWall {} +class MockBody extends Mock implements Body {} + class MockBall extends Mock implements Ball {} class MockContact extends Mock implements Contact {} +class MockRampOpening extends Mock implements RampOpening {} + +class MockRampOpeningBallContactCallback extends Mock + implements RampOpeningBallContactCallback {} + class MockGameBloc extends Mock implements GameBloc {} class MockGameState extends Mock implements GameState {}