diff --git a/.gitignore b/.gitignore index 9bf37325..e47b373d 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,6 @@ app.*.map.json # Firebase related .firebase -web/__/firebase/init.js + +test/.test_runner.dart +web/__/firebase/init.js \ No newline at end of file diff --git a/assets/images/components/sauce.png b/assets/images/components/sauce.png new file mode 100644 index 00000000..743a920a Binary files /dev/null and b/assets/images/components/sauce.png differ diff --git a/assets/images/components/spaceship/android-bottom.png b/assets/images/components/spaceship/android-bottom.png new file mode 100644 index 00000000..90dfdc01 Binary files /dev/null and b/assets/images/components/spaceship/android-bottom.png differ diff --git a/assets/images/components/spaceship/android-top.png b/assets/images/components/spaceship/android-top.png new file mode 100644 index 00000000..92c99db7 Binary files /dev/null and b/assets/images/components/spaceship/android-top.png differ diff --git a/assets/images/components/spaceship/lower.png b/assets/images/components/spaceship/lower.png new file mode 100644 index 00000000..1f0d9b10 Binary files /dev/null and b/assets/images/components/spaceship/lower.png differ diff --git a/assets/images/components/spaceship/saucer.png b/assets/images/components/spaceship/saucer.png new file mode 100644 index 00000000..93af98b5 Binary files /dev/null and b/assets/images/components/spaceship/saucer.png differ diff --git a/assets/images/components/spaceship/upper.png b/assets/images/components/spaceship/upper.png new file mode 100644 index 00000000..0e03cec8 Binary files /dev/null and b/assets/images/components/spaceship/upper.png differ diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index 01990225..3dc068c2 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..84525166 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -5,10 +5,15 @@ 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'; +export 'spaceship.dart'; export 'wall.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/components/spaceship.dart b/lib/game/components/spaceship.dart new file mode 100644 index 00000000..eb03c5a4 --- /dev/null +++ b/lib/game/components/spaceship.dart @@ -0,0 +1,331 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +// TODO(erickzanardo): change this to use the layer class +// that will be introduced on the path PR +const _spaceShipBits = 0x0002; +const _spaceShipSize = 20.0; + +/// {@template spaceship_saucer} +/// A [BodyComponent] for the base, or the saucer of the spaceship +/// {@endtemplate} +class SpaceshipSaucer extends BodyComponent with InitialPosition { + /// {@macro spaceship_saucer} + SpaceshipSaucer() : super(priority: 2); + + /// Path for the base sprite + static const saucerSpritePath = 'components/spaceship/saucer.png'; + + /// Path for the upper wall sprite + static const upperWallPath = 'components/spaceship/upper.png'; + + @override + Future onLoad() async { + await super.onLoad(); + final sprites = await Future.wait([ + gameRef.loadSprite(saucerSpritePath), + gameRef.loadSprite(upperWallPath), + ]); + + await add( + SpriteComponent( + sprite: sprites.first, + size: Vector2.all(_spaceShipSize), + anchor: Anchor.center, + ), + ); + + await add( + SpriteComponent( + sprite: sprites.last, + size: Vector2(_spaceShipSize + 0.5, _spaceShipSize / 2), + anchor: Anchor.center, + position: Vector2(0, -(_spaceShipSize / 3.5)), + ), + ); + + renderBody = false; + } + + @override + Body createBody() { + final circleShape = CircleShape()..radius = _spaceShipSize / 2; + + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..type = BodyType.static; + + return world.createBody(bodyDef) + ..createFixture( + FixtureDef(circleShape) + ..isSensor = true + ..filter.maskBits = _spaceShipBits + ..filter.categoryBits = _spaceShipBits, + ); + } +} + +/// {@spaceship_bridge_top} +/// The bridge of the spaceship (the android head) is divided in two +// [BodyComponent]s, this is the top part of it which contains a single sprite +/// {@endtemplate} +class SpaceshipBridgeTop extends BodyComponent with InitialPosition { + /// {@macro spaceship_bridge_top} + SpaceshipBridgeTop() : super(priority: 6); + + /// Path to the top of this sprite + static const spritePath = 'components/spaceship/android-top.png'; + + @override + Future onLoad() async { + await super.onLoad(); + + final sprite = await gameRef.loadSprite(spritePath); + await add( + SpriteComponent( + sprite: sprite, + anchor: Anchor.center, + size: Vector2(_spaceShipSize / 2.5 - 1, _spaceShipSize / 5), + ), + ); + } + + @override + Body createBody() { + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..type = BodyType.static; + + return world.createBody(bodyDef); + } +} + +/// {@template spaceship_bridge} +/// The main part of the [SpaceshipBridge], this [BodyComponent] +/// provides both the collision and the rotation animation for the bridge. +/// {@endtemplate} +class SpaceshipBridge extends BodyComponent with InitialPosition { + /// {@macro spaceship_bridge} + SpaceshipBridge() : super(priority: 3); + + /// Path to the spaceship bridge + static const spritePath = 'components/spaceship/android-bottom.png'; + + @override + Future onLoad() async { + await super.onLoad(); + + renderBody = false; + + final sprite = await gameRef.images.load(spritePath); + await add( + SpriteAnimationComponent.fromFrameData( + sprite, + SpriteAnimationData.sequenced( + amount: 14, + stepTime: 0.2, + textureSize: Vector2(160, 114), + ), + size: Vector2.all(_spaceShipSize / 2.5), + anchor: Anchor.center, + ), + ); + } + + @override + Body createBody() { + final circleShape = CircleShape()..radius = _spaceShipSize / 5; + + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..type = BodyType.static; + + return world.createBody(bodyDef) + ..createFixture( + FixtureDef(circleShape) + ..restitution = 0.4 + ..filter.maskBits = _spaceShipBits + ..filter.categoryBits = _spaceShipBits, + ); + } +} + +/// {@template spaceship_entrance} +/// A sensor [BodyComponent] used to detect when the ball enters the +/// the spaceship area in order to modify its filter data so the ball +/// can correctly collide only with the Spaceship +/// {@endtemplate} +// TODO(erickzanardo): Use RampOpening once provided. +class SpaceshipEntrance extends BodyComponent with InitialPosition { + /// {@macro spaceship_entrance} + SpaceshipEntrance(); + + @override + Body createBody() { + const radius = _spaceShipSize / 2; + final entranceShape = PolygonShape() + ..setAsEdge( + Vector2( + radius * cos(20 * pi / 180), + radius * sin(20 * pi / 180), + ), + Vector2( + radius * cos(340 * pi / 180), + radius * sin(340 * pi / 180), + ), + ); + + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..angle = 90 * pi / 180 + ..type = BodyType.static; + + return world.createBody(bodyDef) + ..createFixture( + FixtureDef(entranceShape)..isSensor = true, + ); + } +} + +/// {@template spaceship_hole} +/// A sensor [BodyComponent] responsible for sending the [Ball] +/// back to the board. +/// {@endtemplate} +class SpaceshipHole extends BodyComponent with InitialPosition { + /// {@macro spaceship_hole} + SpaceshipHole(); + + @override + Body createBody() { + renderBody = false; + final circleShape = CircleShape()..radius = _spaceShipSize / 80; + + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..type = BodyType.static; + + return world.createBody(bodyDef) + ..createFixture( + FixtureDef(circleShape) + ..isSensor = true + ..filter.maskBits = _spaceShipBits + ..filter.categoryBits = _spaceShipBits, + ); + } +} + +/// {@template spaceship_wall} +/// A [BodyComponent] that provides the collision for the wall +/// surrounding the spaceship, with a small opening to allow the +/// [Ball] to get inside the spaceship saucer. +/// It also contains the [SpriteComponent] for the lower wall +/// {@endtemplate} +class SpaceshipWall extends BodyComponent with InitialPosition { + /// {@macro spaceship_wall} + SpaceshipWall() : super(priority: 4); + + /// Sprite path for the lower wall + static const lowerWallPath = 'components/spaceship/lower.png'; + + @override + Future onLoad() async { + await super.onLoad(); + + final sprite = await gameRef.loadSprite(lowerWallPath); + + await add( + SpriteComponent( + sprite: sprite, + size: Vector2(_spaceShipSize, (_spaceShipSize / 2) + 1), + anchor: Anchor.center, + position: Vector2(-_spaceShipSize / 4, 0), + angle: 90 * pi / 180, + ), + ); + } + + @override + Body createBody() { + renderBody = false; + + const radius = _spaceShipSize / 2; + + final wallShape = ChainShape() + ..createChain( + [ + for (var angle = 20; angle <= 340; angle++) + Vector2( + radius * cos(angle * pi / 180), + radius * sin(angle * pi / 180), + ), + ], + ); + + final bodyDef = BodyDef() + ..userData = this + ..position = initialPosition + ..angle = 90 * pi / 180 + ..type = BodyType.static; + + return world.createBody(bodyDef) + ..createFixture( + FixtureDef(wallShape) + ..restitution = 1 + ..filter.maskBits = _spaceShipBits + ..filter.categoryBits = _spaceShipBits, + ); + } +} + +/// [ContactCallback] that handles the contact between the [Ball] +/// and the [SpaceshipEntrance]. +/// +/// It modifies the [Ball] priority and filter data so it can appear on top of +/// the spaceship and also only collide with the spaceship. +// TODO(alestiago): modify once Layer is implemented in Spaceship. +class SpaceshipEntranceBallContactCallback + extends ContactCallback { + @override + void begin(SpaceshipEntrance entrance, Ball ball, _) { + ball + ..priority = 3 + ..gameRef.reorderChildren(); + + for (final fixture in ball.body.fixtures) { + fixture.filterData.categoryBits = _spaceShipBits; + fixture.filterData.maskBits = _spaceShipBits; + } + } +} + +/// [ContactCallback] that handles the contact between the [Ball] +/// and a [SpaceshipHole]. +/// +/// It resets the [Ball] priority and filter data so it will "be back" on the +/// board. +// TODO(alestiago): modify once Layer is implemented in Spaceship. +class SpaceshipHoleBallContactCallback + extends ContactCallback { + @override + void begin(SpaceshipHole hole, Ball ball, _) { + ball + ..priority = 1 + ..gameRef.reorderChildren(); + + for (final fixture in ball.body.fixtures) { + fixture.filterData.categoryBits = 0xFFFF; + fixture.filterData.maskBits = 0x0001; + } + } +} diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index 778e2bc2..00e9d09c 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -7,6 +7,10 @@ extension PinballGameAssetsX on PinballGame { await Future.wait([ images.load(Ball.spritePath), images.load(Flipper.spritePath), + images.load(SpaceshipBridge.spritePath), + images.load(SpaceshipBridgeTop.spritePath), + images.load(SpaceshipWall.lowerWallPath), + images.load(SpaceshipSaucer.upperWallPath), ]); } } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index c0017b33..c02b74cd 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -27,6 +27,9 @@ class PinballGame extends Forge2DGame await _addGameBoundaries(); unawaited(_addPlunger()); + unawaited(_addPaths()); + + unawaited(_addSpaceship()); // Corner wall above plunger so the ball deflects into the rest of the // board. @@ -77,6 +80,21 @@ class PinballGame extends Forge2DGame ); } + Future _addSpaceship() async { + final position = Vector2(20, -24); + await addAll( + [ + SpaceshipSaucer()..initialPosition = position, + SpaceshipEntrance()..initialPosition = position, + SpaceshipBridge()..initialPosition = position, + SpaceshipBridgeTop()..initialPosition = position + Vector2(0, 5.5), + SpaceshipHole()..initialPosition = position - Vector2(5, 4), + SpaceshipHole()..initialPosition = position - Vector2(-5, 4), + SpaceshipWall()..initialPosition = position, + ], + ); + } + void spawnBall() { final ball = Ball(); add( @@ -89,6 +107,8 @@ class PinballGame extends Forge2DGame addContactCallback(BallScorePointsCallback()); addContactCallback(BottomWallBallContactCallback()); addContactCallback(BonusLetterBallContactCallback()); + addContactCallback(SpaceshipHoleBallContactCallback()); + addContactCallback(SpaceshipEntranceBallContactCallback()); } Future _addGameBoundaries() async { @@ -96,13 +116,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/pubspec.yaml b/pubspec.yaml index 81b056b3..35d8190f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,3 +41,4 @@ flutter: assets: - assets/images/components/ + - assets/images/components/spaceship/ diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index d4f0d5bc..90eb9a1e 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/components/spaceship_test.dart b/test/game/components/spaceship_test.dart new file mode 100644 index 00000000..7e16edd8 --- /dev/null +++ b/test/game/components/spaceship_test.dart @@ -0,0 +1,103 @@ +import 'package:flame_forge2d/flame_forge2d.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() { + group('Spaceship', () { + late Filter filterData; + late Fixture fixture; + late Body body; + late PinballGame game; + late Ball ball; + late SpaceshipEntrance entrance; + late SpaceshipHole hole; + + setUp(() { + filterData = MockFilter(); + + fixture = MockFixture(); + when(() => fixture.filterData).thenReturn(filterData); + + body = MockBody(); + when(() => body.fixtures).thenReturn([fixture]); + + game = MockPinballGame(); + + ball = MockBall(); + when(() => ball.gameRef).thenReturn(game); + when(() => ball.body).thenReturn(body); + + entrance = MockSpaceshipEntrance(); + hole = MockSpaceshipHole(); + }); + + group('SpaceshipEntranceBallContactCallback', () { + test('changes the ball priority on contact', () { + SpaceshipEntranceBallContactCallback().begin( + entrance, + ball, + MockContact(), + ); + + verify(() => ball.priority = 3).called(1); + }); + + test('re order the game children', () { + SpaceshipEntranceBallContactCallback().begin( + entrance, + ball, + MockContact(), + ); + + verify(game.reorderChildren).called(1); + }); + + test('changes the filter data from the ball fixtures', () { + SpaceshipEntranceBallContactCallback().begin( + entrance, + ball, + MockContact(), + ); + + verify(() => filterData.maskBits = 0x0002).called(1); + verify(() => filterData.categoryBits = 0x0002).called(1); + }); + }); + + group('SpaceshipHoleBallContactCallback', () { + test('changes the ball priority on contact', () { + SpaceshipHoleBallContactCallback().begin( + hole, + ball, + MockContact(), + ); + + verify(() => ball.priority = 1).called(1); + }); + + test('re order the game children', () { + SpaceshipHoleBallContactCallback().begin( + hole, + ball, + MockContact(), + ); + + verify(game.reorderChildren).called(1); + }); + + test('changes the filter data from the ball fixtures', () { + SpaceshipHoleBallContactCallback().begin( + hole, + ball, + MockContact(), + ); + + verify(() => filterData.categoryBits = 0xFFFF).called(1); + verify(() => filterData.maskBits = 0x0001).called(1); + }); + }); + }); +} 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..4ce05663 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 {} @@ -41,3 +48,11 @@ class MockTapUpInfo extends Mock implements TapUpInfo {} class MockEventPosition extends Mock implements EventPosition {} class MockBonusLetter extends Mock implements BonusLetter {} + +class MockFilter extends Mock implements Filter {} + +class MockFixture extends Mock implements Fixture {} + +class MockSpaceshipEntrance extends Mock implements SpaceshipEntrance {} + +class MockSpaceshipHole extends Mock implements SpaceshipHole {}