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/components.dart b/lib/game/components/components.dart index ea299c7b..84525166 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -15,4 +15,5 @@ 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/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 18835c98..c02b74cd 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -29,6 +29,8 @@ class PinballGame extends Forge2DGame unawaited(_addPlunger()); unawaited(_addPaths()); + unawaited(_addSpaceship()); + // Corner wall above plunger so the ball deflects into the rest of the // board. // TODO(allisonryan0002): remove once we have the launch track for the ball. @@ -78,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( @@ -90,6 +107,8 @@ class PinballGame extends Forge2DGame addContactCallback(BallScorePointsCallback()); addContactCallback(BottomWallBallContactCallback()); addContactCallback(BonusLetterBallContactCallback()); + addContactCallback(SpaceshipHoleBallContactCallback()); + addContactCallback(SpaceshipEntranceBallContactCallback()); } Future _addGameBoundaries() async { 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/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/helpers/mocks.dart b/test/helpers/mocks.dart index cace878a..4ce05663 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -48,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 {}