From 74e448cbf9b000a827d54655aa5e9057633588b5 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Mon, 21 Mar 2022 13:35:39 +0000 Subject: [PATCH 01/10] docs: renamed Anchor reference (#64) * docs: renamed Anchor reference * docs: updated Anchor reference --- lib/game/components/flipper.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart index 1bdf8d0f..77238acd 100644 --- a/lib/game/components/flipper.dart +++ b/lib/game/components/flipper.dart @@ -216,7 +216,7 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { } /// {@template flipper_anchor} -/// [Anchor] positioned at the end of a [Flipper]. +/// [JointAnchor] positioned at the end of a [Flipper]. /// /// The end of a [Flipper] depends on its [Flipper.side]. /// {@endtemplate} @@ -235,7 +235,7 @@ class FlipperAnchor extends JointAnchor { } /// {@template flipper_anchor_revolute_joint_def} -/// Hinges one end of [Flipper] to a [Anchor] to achieve an arc motion. +/// Hinges one end of [Flipper] to a [FlipperAnchor] to achieve an arc motion. /// {@endtemplate} class FlipperAnchorRevoluteJointDef extends RevoluteJointDef { /// {@macro flipper_anchor_revolute_joint_def} From 1af0c4732891b14e69354a6f355943f6b247b2e6 Mon Sep 17 00:00:00 2001 From: Erick Date: Mon, 21 Mar 2022 10:49:12 -0300 Subject: [PATCH 02/10] feat: Blueprints as a method of grouping game components (#62) * feat: Blueprints as a method of grouping game components * fix: lint * feat: pr suggestions * feat: pr suggestions * fix: test from merge --- lib/flame/blueprint.dart | 87 +++++++++++++++++++++++ lib/game/components/spaceship.dart | 63 +++++++++++------ lib/game/pinball_game.dart | 20 +----- test/flame/blueprint_test.dart | 103 ++++++++++++++++++++++++++++ test/game/components/ball_test.dart | 2 +- test/helpers/mocks.dart | 3 + 6 files changed, 239 insertions(+), 39 deletions(-) create mode 100644 lib/flame/blueprint.dart create mode 100644 test/flame/blueprint_test.dart diff --git a/lib/flame/blueprint.dart b/lib/flame/blueprint.dart new file mode 100644 index 00000000..9f2a68f6 --- /dev/null +++ b/lib/flame/blueprint.dart @@ -0,0 +1,87 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/foundation.dart'; + +const _attachedErrorMessage = "Can't add to attached Blueprints"; + +// TODO(erickzanardo): Keeping this inside our code base +// so we can experiment with the idea, but this is a +// potential upstream change on Flame. + +/// A [Blueprint] is a virtual way of grouping [Component]s +/// that are related, but they need to be added directly on +/// the [FlameGame] level. +abstract class Blueprint { + final List _components = []; + bool _isAttached = false; + + /// Called before the the [Component]s managed + /// by this blueprint is added to the [FlameGame] + void build(); + + /// Attach the [Component]s built on [build] to the [game] + /// instance + @mustCallSuper + Future attach(FlameGame game) async { + build(); + await game.addAll(_components); + _isAttached = true; + } + + /// Adds a list of [Component]s to this blueprint. + void addAll(List components) { + assert(!_isAttached, _attachedErrorMessage); + _components.addAll(components); + } + + /// Adds a single [Component] to this blueprint. + void add(Component component) { + assert(!_isAttached, _attachedErrorMessage); + _components.add(component); + } + + /// Returns a copy of the components built by this blueprint + List get components => List.unmodifiable(_components); +} + +/// A [Blueprint] that provides additional +/// structures specific to flame_forge2d +abstract class Forge2DBlueprint extends Blueprint { + final List _callbacks = []; + + /// Adds a single [ContactCallback] to this blueprint + void addContactCallback(ContactCallback callback) { + assert(!_isAttached, _attachedErrorMessage); + _callbacks.add(callback); + } + + /// Adds a collection of [ContactCallback]s to this blueprint + void addAllContactCallback(List callbacks) { + assert(!_isAttached, _attachedErrorMessage); + _callbacks.addAll(callbacks); + } + + @override + Future attach(FlameGame game) async { + await super.attach(game); + + assert(game is Forge2DGame, 'Forge2DBlueprint used outside a Forge2DGame'); + + for (final callback in _callbacks) { + (game as Forge2DGame).addContactCallback(callback); + } + } + + /// Returns a copy of the callbacks built by this blueprint + List get callbacks => List.unmodifiable(_callbacks); +} + +/// Adds helper methods regardin [Blueprint]s to [FlameGame] +extension FlameGameBlueprint on FlameGame { + /// Shortcut to attach a [Blueprint] instance to this game + /// equivalent to `MyBluepinrt().attach(game)` + Future addFromBlueprint(Blueprint blueprint) async { + await blueprint.attach(this); + } +} diff --git a/lib/game/components/spaceship.dart b/lib/game/components/spaceship.dart index eb03c5a4..adfca9a5 100644 --- a/lib/game/components/spaceship.dart +++ b/lib/game/components/spaceship.dart @@ -5,12 +5,38 @@ import 'dart:math'; import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/flame/blueprint.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; + +/// A [Blueprint] which creates the spaceship feature. +class Spaceship extends Forge2DBlueprint { + /// Total size of the spaceship + static const radius = 10.0; + + @override + void build() { + final position = Vector2(20, -24); + + addAllContactCallback([ + SpaceshipHoleBallContactCallback(), + SpaceshipEntranceBallContactCallback(), + ]); + + 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, + ]); + } +} /// {@template spaceship_saucer} /// A [BodyComponent] for the base, or the saucer of the spaceship @@ -36,7 +62,7 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition { await add( SpriteComponent( sprite: sprites.first, - size: Vector2.all(_spaceShipSize), + size: Vector2.all(Spaceship.radius * 2), anchor: Anchor.center, ), ); @@ -44,9 +70,9 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition { await add( SpriteComponent( sprite: sprites.last, - size: Vector2(_spaceShipSize + 0.5, _spaceShipSize / 2), + size: Vector2((Spaceship.radius * 2) + 0.5, Spaceship.radius), anchor: Anchor.center, - position: Vector2(0, -(_spaceShipSize / 3.5)), + position: Vector2(0, -((Spaceship.radius * 2) / 3.5)), ), ); @@ -55,7 +81,7 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition { @override Body createBody() { - final circleShape = CircleShape()..radius = _spaceShipSize / 2; + final circleShape = CircleShape()..radius = Spaceship.radius; final bodyDef = BodyDef() ..userData = this @@ -92,7 +118,7 @@ class SpaceshipBridgeTop extends BodyComponent with InitialPosition { SpriteComponent( sprite: sprite, anchor: Anchor.center, - size: Vector2(_spaceShipSize / 2.5 - 1, _spaceShipSize / 5), + size: Vector2((Spaceship.radius * 2) / 2.5 - 1, Spaceship.radius / 2.5), ), ); } @@ -134,7 +160,7 @@ class SpaceshipBridge extends BodyComponent with InitialPosition { stepTime: 0.2, textureSize: Vector2(160, 114), ), - size: Vector2.all(_spaceShipSize / 2.5), + size: Vector2.all((Spaceship.radius * 2) / 2.5), anchor: Anchor.center, ), ); @@ -142,7 +168,7 @@ class SpaceshipBridge extends BodyComponent with InitialPosition { @override Body createBody() { - final circleShape = CircleShape()..radius = _spaceShipSize / 5; + final circleShape = CircleShape()..radius = Spaceship.radius / 2.5; final bodyDef = BodyDef() ..userData = this @@ -171,16 +197,15 @@ class SpaceshipEntrance extends BodyComponent with InitialPosition { @override Body createBody() { - const radius = _spaceShipSize / 2; final entranceShape = PolygonShape() ..setAsEdge( Vector2( - radius * cos(20 * pi / 180), - radius * sin(20 * pi / 180), + Spaceship.radius * cos(20 * pi / 180), + Spaceship.radius * sin(20 * pi / 180), ), Vector2( - radius * cos(340 * pi / 180), - radius * sin(340 * pi / 180), + Spaceship.radius * cos(340 * pi / 180), + Spaceship.radius * sin(340 * pi / 180), ), ); @@ -208,7 +233,7 @@ class SpaceshipHole extends BodyComponent with InitialPosition { @override Body createBody() { renderBody = false; - final circleShape = CircleShape()..radius = _spaceShipSize / 80; + final circleShape = CircleShape()..radius = Spaceship.radius / 40; final bodyDef = BodyDef() ..userData = this @@ -247,9 +272,9 @@ class SpaceshipWall extends BodyComponent with InitialPosition { await add( SpriteComponent( sprite: sprite, - size: Vector2(_spaceShipSize, (_spaceShipSize / 2) + 1), + size: Vector2(Spaceship.radius * 2, Spaceship.radius + 1), anchor: Anchor.center, - position: Vector2(-_spaceShipSize / 4, 0), + position: Vector2(-Spaceship.radius / 2, 0), angle: 90 * pi / 180, ), ); @@ -259,15 +284,13 @@ class SpaceshipWall extends BodyComponent with InitialPosition { 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), + Spaceship.radius * cos(angle * pi / 180), + Spaceship.radius * sin(angle * pi / 180), ), ], ); diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index c02b74cd..ef5cb3b1 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/flame/blueprint.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball_theme/pinball_theme.dart'; @@ -29,7 +30,7 @@ class PinballGame extends Forge2DGame unawaited(_addPlunger()); unawaited(_addPaths()); - unawaited(_addSpaceship()); + unawaited(addFromBlueprint(Spaceship())); // Corner wall above plunger so the ball deflects into the rest of the // board. @@ -80,21 +81,6 @@ 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( @@ -107,8 +93,6 @@ class PinballGame extends Forge2DGame addContactCallback(BallScorePointsCallback()); addContactCallback(BottomWallBallContactCallback()); addContactCallback(BonusLetterBallContactCallback()); - addContactCallback(SpaceshipHoleBallContactCallback()); - addContactCallback(SpaceshipEntranceBallContactCallback()); } Future _addGameBoundaries() async { diff --git a/test/flame/blueprint_test.dart b/test/flame/blueprint_test.dart new file mode 100644 index 00000000..e521a83c --- /dev/null +++ b/test/flame/blueprint_test.dart @@ -0,0 +1,103 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/flame/blueprint.dart'; +import 'package:pinball/game/game.dart'; + +import '../helpers/helpers.dart'; + +class MyBlueprint extends Blueprint { + @override + void build() { + add(Component()); + addAll([Component(), Component()]); + } +} + +class MyForge2dBlueprint extends Forge2DBlueprint { + @override + void build() { + addContactCallback(MockContactCallback()); + addAllContactCallback([MockContactCallback(), MockContactCallback()]); + } +} + +void main() { + group('Blueprint', () { + test('components can be added to it', () { + final blueprint = MyBlueprint()..build(); + + expect(blueprint.components.length, equals(3)); + }); + + test('adds the components to a game on attach', () { + final mockGame = MockPinballGame(); + when(() => mockGame.addAll(any())).thenAnswer((_) async {}); + MyBlueprint().attach(mockGame); + + verify(() => mockGame.addAll(any())).called(1); + }); + + test( + 'throws assertion error when adding to an already attached blueprint', + () async { + final mockGame = MockPinballGame(); + when(() => mockGame.addAll(any())).thenAnswer((_) async {}); + final blueprint = MyBlueprint(); + await blueprint.attach(mockGame); + + expect(() => blueprint.add(Component()), throwsAssertionError); + expect(() => blueprint.addAll([Component()]), throwsAssertionError); + }, + ); + }); + + group('Forge2DBlueprint', () { + setUpAll(() { + registerFallbackValue(SpaceshipHoleBallContactCallback()); + }); + + test('callbacks can be added to it', () { + final blueprint = MyForge2dBlueprint()..build(); + + expect(blueprint.callbacks.length, equals(3)); + }); + + test('adds the callbacks to a game on attach', () async { + final mockGame = MockPinballGame(); + when(() => mockGame.addAll(any())).thenAnswer((_) async {}); + when(() => mockGame.addContactCallback(any())).thenAnswer((_) async {}); + await MyForge2dBlueprint().attach(mockGame); + + verify(() => mockGame.addContactCallback(any())).called(3); + }); + + test( + 'throws assertion error when adding to an already attached blueprint', + () async { + final mockGame = MockPinballGame(); + when(() => mockGame.addAll(any())).thenAnswer((_) async {}); + when(() => mockGame.addContactCallback(any())).thenAnswer((_) async {}); + final blueprint = MyForge2dBlueprint(); + await blueprint.attach(mockGame); + + expect( + () => blueprint.addContactCallback(MockContactCallback()), + throwsAssertionError, + ); + expect( + () => blueprint.addAllContactCallback([MockContactCallback()]), + throwsAssertionError, + ); + }, + ); + + test('throws assertion error when used on a non Forge2dGame', () { + expect( + () => MyForge2dBlueprint().attach(FlameGame()), + throwsAssertionError, + ); + }); + }); +} diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index 3d4b5557..cb6231fa 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -76,7 +76,7 @@ void main() { (game) async { final ball = Ball(); await game.ensureAdd(ball); - await ball.mounted; + await game.ready(); final fixture = ball.body.fixtures[0]; expect(fixture.filterData.maskBits, equals(Layer.board.maskBits)); diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 4ce05663..e1bd8a0c 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -18,6 +18,9 @@ class MockBall extends Mock implements Ball {} class MockContact extends Mock implements Contact {} +class MockContactCallback extends Mock + implements ContactCallback {} + class MockRampOpening extends Mock implements RampOpening {} class MockRampOpeningBallContactCallback extends Mock From 32529e42bea5fd8db7a8f711d84f951a6b24a062 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Mon, 21 Mar 2022 15:20:38 +0000 Subject: [PATCH 03/10] feat: made`SlingShot` rounded (#63) * feat: made SlingShot rounded * feat: updated SlingShot tests * refactor: defined Shape extension * refactor: fixed test name typo Co-authored-by: Rui Miguel Alonso --- lib/game/components/sling_shot.dart | 124 +++++++++++----- test/game/components/ball_test.dart | 1 + test/game/components/sling_shot_test.dart | 164 +++++----------------- 3 files changed, 127 insertions(+), 162 deletions(-) diff --git a/lib/game/components/sling_shot.dart b/lib/game/components/sling_shot.dart index a39d130e..53160213 100644 --- a/lib/game/components/sling_shot.dart +++ b/lib/game/components/sling_shot.dart @@ -1,7 +1,9 @@ +import 'dart:math' as math; + import 'package:flame/extensions.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; -import 'package:geometry/geometry.dart' show centroid; +import 'package:geometry/geometry.dart' as geometry show centroid; import 'package:pinball/game/game.dart'; /// {@template sling_shot} @@ -31,54 +33,88 @@ class SlingShot extends BodyComponent with InitialPosition { /// The size of the [SlingShot] body. // TODO(alestiago): Use size from PositionedBodyComponent instead, // once a sprite is given. - static final Vector2 size = Vector2(6, 8); + static final Vector2 size = Vector2(4, 10); List _createFixtureDefs() { - final fixturesDef = []; - - // TODO(alestiago): This magic number can be deduced by specifying the - // angle and using polar coordinate system to place the bottom right - // vertex. - // Something as: y = -size.y * math.cos(angle) - const additionalIncrement = 3; - final triangleVertices = _side.isLeft - ? [ - Vector2(0, 0), - Vector2(0, -size.y), - Vector2( - size.x, - -size.y - additionalIncrement, - ), - ] - : [ - Vector2(size.x, 0), - Vector2(size.x, -size.y), + final fixturesDefs = []; + final direction = _side.direction; + const quarterPi = math.pi / 4; + + final upperCircle = CircleShape()..radius = 1.45; + upperCircle.position.setValues(0, -upperCircle.radius / 2); + final upperCircleFixtureDef = FixtureDef(upperCircle)..friction = 0; + fixturesDefs.add(upperCircleFixtureDef); + + final lowerCircle = CircleShape()..radius = 1.45; + lowerCircle.position.setValues( + size.x * -direction, + -size.y, + ); + final lowerCircleFixtureDef = FixtureDef(lowerCircle)..friction = 0; + fixturesDefs.add(lowerCircleFixtureDef); + + final wallFacingEdge = EdgeShape() + ..set( + upperCircle.position + Vector2( + upperCircle.radius * direction, 0, - -size.y - additionalIncrement, ), - ]; - final triangleCentroid = centroid(triangleVertices); - for (final vertex in triangleVertices) { - vertex.setFrom(vertex - triangleCentroid); - } + // TODO(alestiago): Use values from design. + Vector2(2.0 * direction, -size.y + 2), + ); + final wallFacingLineFixtureDef = FixtureDef(wallFacingEdge)..friction = 0; + fixturesDefs.add(wallFacingLineFixtureDef); - final triangle = PolygonShape()..set(triangleVertices); - final triangleFixtureDef = FixtureDef(triangle)..friction = 0; - fixturesDef.add(triangleFixtureDef); + final bottomEdge = EdgeShape() + ..set( + wallFacingEdge.vertex2, + lowerCircle.position + + Vector2( + lowerCircle.radius * math.cos(quarterPi) * direction, + -lowerCircle.radius * math.sin(quarterPi), + ), + ); + final bottomLineFixtureDef = FixtureDef(bottomEdge)..friction = 0; + fixturesDefs.add(bottomLineFixtureDef); - final kicker = EdgeShape() + final kickerEdge = EdgeShape() ..set( - triangleVertices.first, - triangleVertices.last, + upperCircle.position + + Vector2( + upperCircle.radius * math.cos(quarterPi) * -direction, + upperCircle.radius * math.sin(quarterPi), + ), + lowerCircle.position + + Vector2( + lowerCircle.radius * math.cos(quarterPi) * -direction, + lowerCircle.radius * math.sin(quarterPi), + ), ); - // TODO(alestiago): Play with restitution value once game is bundled. - final kickerFixtureDef = FixtureDef(kicker) + + final kickerFixtureDef = FixtureDef(kickerEdge) + // TODO(alestiago): Play with restitution value once game is bundled. ..restitution = 10.0 ..friction = 0; - fixturesDef.add(kickerFixtureDef); + fixturesDefs.add(kickerFixtureDef); - return fixturesDef; + // TODO(alestiago): Evaluate if there is value on centering the fixtures. + final centroid = geometry.centroid( + [ + upperCircle.position + Vector2(0, -upperCircle.radius), + lowerCircle.position + + Vector2( + lowerCircle.radius * math.cos(quarterPi) * -direction, + -lowerCircle.radius * math.sin(quarterPi), + ), + wallFacingEdge.vertex2, + ], + ); + for (final fixtureDef in fixturesDefs) { + fixtureDef.shape.moveBy(-centroid); + } + + return fixturesDefs; } @override @@ -90,3 +126,17 @@ class SlingShot extends BodyComponent with InitialPosition { return body; } } + +// TODO(alestiago): Evaluate if there's value on generalising this to +// all shapes. +extension on Shape { + void moveBy(Vector2 offset) { + if (this is CircleShape) { + final circle = this as CircleShape; + circle.position.setFrom(circle.position + offset); + } else if (this is EdgeShape) { + final edge = this as EdgeShape; + edge.set(edge.vertex1 + offset, edge.vertex2 + offset); + } + } +} diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index cb6231fa..01668d1a 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -75,6 +75,7 @@ void main() { 'has Layer.all as default filter maskBits', (game) async { final ball = Ball(); + await game.ready(); await game.ensureAdd(ball); await game.ready(); diff --git a/test/game/components/sling_shot_test.dart b/test/game/components/sling_shot_test.dart index e7c796a1..2bfb2355 100644 --- a/test/game/components/sling_shot_test.dart +++ b/test/game/components/sling_shot_test.dart @@ -7,6 +7,7 @@ import 'package:pinball/game/game.dart'; void main() { group('SlingShot', () { + // TODO(alestiago): Include golden tests for left and right. final flameTester = FlameTester(Forge2DGame.new); flameTester.test( @@ -21,135 +22,48 @@ void main() { }, ); - group('body', () { - flameTester.test( - 'is static', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); - - expect(slingShot.body.bodyType, equals(BodyType.static)); - }, - ); - }); - - group('first fixture', () { - flameTester.test( - 'exists', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); - - expect(slingShot.body.fixtures[0], isA()); - }, - ); - - flameTester.test( - 'shape is triangular', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); - - final fixture = slingShot.body.fixtures[0]; - expect(fixture.shape.shapeType, equals(ShapeType.polygon)); - expect((fixture.shape as PolygonShape).vertices.length, equals(3)); - }, - ); - - flameTester.test( - 'triangular shapes are different ' - 'when side is left or right', - (game) async { - final leftSlingShot = SlingShot( - side: BoardSide.left, - ); - final rightSlingShot = SlingShot( - side: BoardSide.right, - ); - - await game.ensureAdd(leftSlingShot); - await game.ensureAdd(rightSlingShot); - - final rightShape = - rightSlingShot.body.fixtures[0].shape as PolygonShape; - final leftShape = - leftSlingShot.body.fixtures[0].shape as PolygonShape; - - expect(rightShape.vertices, isNot(equals(leftShape.vertices))); - }, - ); - - flameTester.test( - 'has no friction', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); - - final fixture = slingShot.body.fixtures[0]; - expect(fixture.friction, equals(0)); - }, - ); - }); - - group('second fixture', () { - flameTester.test( - 'exists', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); - - expect(slingShot.body.fixtures[1], isA()); - }, - ); - - flameTester.test( - 'shape is edge', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); + flameTester.test( + 'body is static', + (game) async { + final slingShot = SlingShot( + side: BoardSide.left, + ); + await game.ensureAdd(slingShot); - final fixture = slingShot.body.fixtures[1]; - expect(fixture.shape.shapeType, equals(ShapeType.edge)); - }, - ); + expect(slingShot.body.bodyType, equals(BodyType.static)); + }, + ); - flameTester.test( - 'has restitution', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); + flameTester.test( + 'has restitution', + (game) async { + final slingShot = SlingShot( + side: BoardSide.left, + ); + await game.ensureAdd(slingShot); - final fixture = slingShot.body.fixtures[1]; - expect(fixture.restitution, greaterThan(0)); - }, - ); + final totalRestitution = slingShot.body.fixtures.fold( + 0, + (total, fixture) => total + fixture.restitution, + ); + expect(totalRestitution, greaterThan(0)); + }, + ); - flameTester.test( - 'has no friction', - (game) async { - final slingShot = SlingShot( - side: BoardSide.left, - ); - await game.ensureAdd(slingShot); + flameTester.test( + 'has no friction', + (game) async { + final slingShot = SlingShot( + side: BoardSide.left, + ); + await game.ensureAdd(slingShot); - final fixture = slingShot.body.fixtures[1]; - expect(fixture.friction, equals(0)); - }, - ); - }); + final totalFriction = slingShot.body.fixtures.fold( + 0, + (total, fixture) => total + fixture.friction, + ); + expect(totalFriction, equals(0)); + }, + ); }); } From 38b28243a7080a9741db28c4212dd0d5f6e3a0c5 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Mon, 21 Mar 2022 16:13:39 +0000 Subject: [PATCH 04/10] feat: allow `Ball` to be stopped and resumed (#58) * feat: implemented stop and reusme methods * feat: included tests * docs: improved doc comment * feat: improved tests * feat: removed gravity from tests * refactor: renamed tests --- lib/game/components/ball.dart | 15 ++++++ test/game/components/ball_test.dart | 75 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index 131e7e10..3dc068c2 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -68,4 +68,19 @@ class Ball extends BodyComponent with InitialPosition, Layered { gameRef.spawnBall(); } } + + /// Immediatly and completly [stop]s the ball. + /// + /// The [Ball] will no longer be affected by any forces, including it's + /// weight and those emitted from collisions. + void stop() { + body.setType(BodyType.static); + } + + /// Allows the [Ball] to be affected by forces. + /// + /// If previously [stop]ed, the previous ball's velocity is not kept. + void resume() { + body.setType(BodyType.dynamic); + } } diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index 01668d1a..7a48b21f 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -35,6 +35,26 @@ void main() { expect(ball.body.bodyType, equals(BodyType.dynamic)); }, ); + + group('can be moved', () { + flameTester.test('by its weight', (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + + game.update(1); + expect(ball.body.position, isNot(equals(ball.initialPosition))); + }); + + flameTester.test('by applying velocity', (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + + ball.body.gravityScale = 0; + ball.body.linearVelocity.setValues(10, 10); + game.update(1); + expect(ball.body.position, isNot(equals(ball.initialPosition))); + }); + }); }); group('fixture', () { @@ -151,5 +171,60 @@ void main() { }, ); }); + + group('stop', () { + group("can't be moved", () { + flameTester.test('by its weight', (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + ball.stop(); + + game.update(1); + expect(ball.body.position, equals(ball.initialPosition)); + }); + }); + + flameTester.test('by applying velocity', (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + ball.stop(); + + ball.body.linearVelocity.setValues(10, 10); + game.update(1); + expect(ball.body.position, equals(ball.initialPosition)); + }); + }); + + group('resume', () { + group('can move', () { + flameTester.test( + 'by its weight when previously stopped', + (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + ball.stop(); + ball.resume(); + + game.update(1); + expect(ball.body.position, isNot(equals(ball.initialPosition))); + }, + ); + + flameTester.test( + 'by applying velocity when previously stopped', + (game) async { + final ball = Ball(); + await game.ensureAdd(ball); + ball.stop(); + ball.resume(); + + ball.body.gravityScale = 0; + ball.body.linearVelocity.setValues(10, 10); + game.update(1); + expect(ball.body.position, isNot(equals(ball.initialPosition))); + }, + ); + }); + }); }); } From aa1a2d76748b53de641673a898b993fc4d962eec Mon Sep 17 00:00:00 2001 From: Rui Miguel Alonso Date: Mon, 21 Mar 2022 20:20:31 +0100 Subject: [PATCH 05/10] feat: connect spaceship ramp (#65) * refactor: improve layer and ramp to allow connection between different layers outside from board * refactor: modified spaceship to be Layered and RampOpening * refactor: moved ramp and game components to connect jetpack ramp with spaceship * test: test coverage and removed layer unnecessary tests for spaceship * refactor: jetpack ramp removed rotation * refactor: hardcoded layer spaceship inside each component Co-authored-by: Erick --- lib/game/components/jetpack_ramp.dart | 40 ++++----- lib/game/components/layer.dart | 5 ++ lib/game/components/ramp_opening.dart | 12 ++- lib/game/components/spaceship.dart | 107 +++++++++-------------- lib/game/pinball_game.dart | 89 +++++++------------ test/game/components/spaceship_test.dart | 22 ----- test/game/pinball_game_test.dart | 4 +- 7 files changed, 104 insertions(+), 175 deletions(-) diff --git a/lib/game/components/jetpack_ramp.dart b/lib/game/components/jetpack_ramp.dart index a11ac40c..985c8f7d 100644 --- a/lib/game/components/jetpack_ramp.dart +++ b/lib/game/components/jetpack_ramp.dart @@ -30,22 +30,18 @@ class JetpackRamp extends Component with HasGameRef { // TODO(ruialonso): Use a bezier curve once control points are defined. color: const Color.fromARGB(255, 8, 218, 241), center: position, - width: 80, + width: 62, radius: 200, - angle: 7 * math.pi / 6, - rotation: -math.pi / 18, + angle: math.pi, ) ..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) + final leftOpening = _JetpackRampOpening(outsideLayer: Layer.spaceship) + ..initialPosition = position + Vector2(-27.6, 25.3) + ..layer = Layer.jetpack; + + final rightOpening = _JetpackRampOpening() + ..initialPosition = position + Vector2(-10.6, 25.3) ..layer = Layer.opening; await addAll([ @@ -63,25 +59,21 @@ class JetpackRamp extends Component with HasGameRef { class _JetpackRampOpening extends RampOpening { /// {@macro jetpack_ramp_opening} _JetpackRampOpening({ - required double rotation, - }) : _rotation = rotation, - super( + Layer? outsideLayer, + }) : super( pathwayLayer: Layer.jetpack, + outsideLayer: outsideLayer, orientation: RampOrientation.down, ); - final double _rotation; - - // TODO(ruialonso): Avoid magic number 3, should be propotional to + // TODO(ruialonso): Avoid magic number 2, should be proportional to // [JetpackRamp]. - static final Vector2 _size = Vector2(3, .1); + static const _size = 2; @override Shape get shape => PolygonShape() - ..setAsBox( - _size.x, - _size.y, - initialPosition, - _rotation, + ..setAsEdge( + Vector2(initialPosition.x - _size, initialPosition.y), + Vector2(initialPosition.x + _size, initialPosition.y), ); } diff --git a/lib/game/components/layer.dart b/lib/game/components/layer.dart index d5df0698..3cc0471f 100644 --- a/lib/game/components/layer.dart +++ b/lib/game/components/layer.dart @@ -55,6 +55,9 @@ enum Layer { /// Collide only with Launcher group elements. launcher, + + /// Collide only with Spaceship group elements. + spaceship, } /// {@template layer_mask_bits} @@ -81,6 +84,8 @@ extension LayerMaskBits on Layer { return 0x0002; case Layer.launcher: return 0x0005; + case Layer.spaceship: + return 0x000A; } } } diff --git a/lib/game/components/ramp_opening.dart b/lib/game/components/ramp_opening.dart index 4ff8f8c9..2d8454dd 100644 --- a/lib/game/components/ramp_opening.dart +++ b/lib/game/components/ramp_opening.dart @@ -27,15 +27,21 @@ abstract class RampOpening extends BodyComponent with InitialPosition, Layered { /// {@macro ramp_opening} RampOpening({ required Layer pathwayLayer, + Layer? outsideLayer, required this.orientation, - }) : _pathwayLayer = pathwayLayer { + }) : _pathwayLayer = pathwayLayer, + _outsideLayer = outsideLayer ?? Layer.board { layer = Layer.board; } final Layer _pathwayLayer; + final Layer _outsideLayer; /// Mask of category bits for collision inside [Pathway]. Layer get pathwayLayer => _pathwayLayer; + /// Mask of category bits for collision outside [Pathway]. + Layer get outsideLayer => _outsideLayer; + /// The [Shape] of the [RampOpening]. Shape get shape; @@ -85,7 +91,7 @@ class RampOpeningBallContactCallback @override void end(Ball ball, Opening opening, Contact _) { if (!_ballsInside.contains(ball)) { - ball.layer = Layer.board; + ball.layer = opening.outsideLayer; } else { // TODO(ruimiguel): change this code. Check what happens with ball that // slightly touch Opening and goes out again. With InitialPosition change @@ -97,7 +103,7 @@ class RampOpeningBallContactCallback ball.body.linearVelocity.y > 0); if (isBallOutsideOpening) { - ball.layer = Layer.board; + ball.layer = opening.outsideLayer; _ballsInside.remove(ball); } } diff --git a/lib/game/components/spaceship.dart b/lib/game/components/spaceship.dart index adfca9a5..f934d943 100644 --- a/lib/game/components/spaceship.dart +++ b/lib/game/components/spaceship.dart @@ -8,10 +8,6 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/flame/blueprint.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; - /// A [Blueprint] which creates the spaceship feature. class Spaceship extends Forge2DBlueprint { /// Total size of the spaceship @@ -19,7 +15,7 @@ class Spaceship extends Forge2DBlueprint { @override void build() { - final position = Vector2(20, -24); + final position = Vector2(30, -50); addAllContactCallback([ SpaceshipHoleBallContactCallback(), @@ -41,9 +37,11 @@ class Spaceship extends Forge2DBlueprint { /// {@template spaceship_saucer} /// A [BodyComponent] for the base, or the saucer of the spaceship /// {@endtemplate} -class SpaceshipSaucer extends BodyComponent with InitialPosition { +class SpaceshipSaucer extends BodyComponent with InitialPosition, Layered { /// {@macro spaceship_saucer} - SpaceshipSaucer() : super(priority: 2); + SpaceshipSaucer() : super(priority: 2) { + layer = Layer.spaceship; + } /// Path for the base sprite static const saucerSpritePath = 'components/spaceship/saucer.png'; @@ -90,10 +88,7 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition { return world.createBody(bodyDef) ..createFixture( - FixtureDef(circleShape) - ..isSensor = true - ..filter.maskBits = _spaceShipBits - ..filter.categoryBits = _spaceShipBits, + FixtureDef(circleShape)..isSensor = true, ); } } @@ -138,9 +133,11 @@ class SpaceshipBridgeTop extends BodyComponent with InitialPosition { /// 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 { +class SpaceshipBridge extends BodyComponent with InitialPosition, Layered { /// {@macro spaceship_bridge} - SpaceshipBridge() : super(priority: 3); + SpaceshipBridge() : super(priority: 3) { + layer = Layer.spaceship; + } /// Path to the spaceship bridge static const spritePath = 'components/spaceship/android-bottom.png'; @@ -177,10 +174,7 @@ class SpaceshipBridge extends BodyComponent with InitialPosition { return world.createBody(bodyDef) ..createFixture( - FixtureDef(circleShape) - ..restitution = 0.4 - ..filter.maskBits = _spaceShipBits - ..filter.categoryBits = _spaceShipBits, + FixtureDef(circleShape)..restitution = 0.4, ); } } @@ -190,34 +184,29 @@ class SpaceshipBridge extends BodyComponent with InitialPosition { /// 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 { +class SpaceshipEntrance extends RampOpening { /// {@macro spaceship_entrance} - SpaceshipEntrance(); + SpaceshipEntrance() + : super( + pathwayLayer: Layer.spaceship, + orientation: RampOrientation.up, + ) { + layer = Layer.spaceship; + } @override - Body createBody() { - final entranceShape = PolygonShape() + Shape get shape { + const radius = Spaceship.radius * 2; + return PolygonShape() ..setAsEdge( Vector2( - Spaceship.radius * cos(20 * pi / 180), - Spaceship.radius * sin(20 * pi / 180), - ), + radius * cos(20 * pi / 180), + radius * sin(20 * pi / 180), + )..rotate(90 * pi / 180), Vector2( - Spaceship.radius * cos(340 * pi / 180), - Spaceship.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, + radius * cos(340 * pi / 180), + radius * sin(340 * pi / 180), + )..rotate(90 * pi / 180), ); } } @@ -226,9 +215,11 @@ class SpaceshipEntrance extends BodyComponent with InitialPosition { /// A sensor [BodyComponent] responsible for sending the [Ball] /// back to the board. /// {@endtemplate} -class SpaceshipHole extends BodyComponent with InitialPosition { +class SpaceshipHole extends BodyComponent with InitialPosition, Layered { /// {@macro spaceship_hole} - SpaceshipHole(); + SpaceshipHole() { + layer = Layer.spaceship; + } @override Body createBody() { @@ -242,10 +233,7 @@ class SpaceshipHole extends BodyComponent with InitialPosition { return world.createBody(bodyDef) ..createFixture( - FixtureDef(circleShape) - ..isSensor = true - ..filter.maskBits = _spaceShipBits - ..filter.categoryBits = _spaceShipBits, + FixtureDef(circleShape)..isSensor = true, ); } } @@ -256,9 +244,11 @@ class SpaceshipHole extends BodyComponent with InitialPosition { /// [Ball] to get inside the spaceship saucer. /// It also contains the [SpriteComponent] for the lower wall /// {@endtemplate} -class SpaceshipWall extends BodyComponent with InitialPosition { +class SpaceshipWall extends BodyComponent with InitialPosition, Layered { /// {@macro spaceship_wall} - SpaceshipWall() : super(priority: 4); + SpaceshipWall() : super(priority: 4) { + layer = Layer.spaceship; + } /// Sprite path for the lower wall static const lowerWallPath = 'components/spaceship/lower.png'; @@ -303,10 +293,7 @@ class SpaceshipWall extends BodyComponent with InitialPosition { return world.createBody(bodyDef) ..createFixture( - FixtureDef(wallShape) - ..restitution = 1 - ..filter.maskBits = _spaceShipBits - ..filter.categoryBits = _spaceShipBits, + FixtureDef(wallShape)..restitution = 1, ); } } @@ -316,19 +303,14 @@ class SpaceshipWall extends BodyComponent with InitialPosition { /// /// 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; - } + ..gameRef.reorderChildren() + ..layer = Layer.spaceship; } } @@ -337,18 +319,13 @@ class SpaceshipEntranceBallContactCallback /// /// 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; - } + ..gameRef.reorderChildren() + ..layer = Layer.board; } } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index ef5cb3b1..86bceef6 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -27,33 +27,22 @@ class PinballGame extends Forge2DGame _addContactCallbacks(); await _addGameBoundaries(); + unawaited(_addBoard()); unawaited(_addPlunger()); + unawaited(_addBonusWord()); unawaited(_addPaths()); - unawaited(addFromBlueprint(Spaceship())); + } - // 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. - await add( - Wall( - start: screenToWorld( - Vector2( - camera.viewport.effectiveSize.x, - 100, - ), - ), - end: screenToWorld( - Vector2( - camera.viewport.effectiveSize.x - 100, - 0, - ), - ), - ), - ); + void _addContactCallbacks() { + addContactCallback(BallScorePointsCallback()); + addContactCallback(BottomWallBallContactCallback()); + addContactCallback(BonusLetterBallContactCallback()); + } - unawaited(_addBonusWord()); - unawaited(_addBoard()); + Future _addGameBoundaries() async { + await add(BottomWall(this)); + createBoundaries(this).forEach(add); } Future _addBoard() async { @@ -68,6 +57,20 @@ class PinballGame extends Forge2DGame await add(board); } + Future _addPlunger() async { + plunger = Plunger( + compressionDistance: camera.viewport.effectiveSize.y / 12, + ); + plunger.initialPosition = screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 + 450, + camera.viewport.effectiveSize.y - plunger.compressionDistance, + ), + ); + + await add(plunger); + } + Future _addBonusWord() async { await add( BonusWord( @@ -81,33 +84,9 @@ class PinballGame extends Forge2DGame ); } - void spawnBall() { - final ball = Ball(); - add( - ball - ..initialPosition = plunger.body.position + Vector2(0, ball.size.y / 2), - ); - } - - void _addContactCallbacks() { - addContactCallback(BallScorePointsCallback()); - addContactCallback(BottomWallBallContactCallback()); - addContactCallback(BonusLetterBallContactCallback()); - } - - Future _addGameBoundaries() async { - await add(BottomWall(this)); - 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, - ), - ), + position: Vector2(42.6, -45), ); final launcherRamp = LauncherRamp( position: screenToWorld( @@ -121,18 +100,12 @@ class PinballGame extends Forge2DGame await addAll([jetpackRamp, launcherRamp]); } - Future _addPlunger() async { - plunger = Plunger( - compressionDistance: camera.viewport.effectiveSize.y / 12, - ); - plunger.initialPosition = screenToWorld( - Vector2( - camera.viewport.effectiveSize.x / 2 + 450, - camera.viewport.effectiveSize.y - plunger.compressionDistance, - ), + void spawnBall() { + final ball = Ball(); + add( + ball + ..initialPosition = plunger.body.position + Vector2(0, ball.size.y / 2), ); - - await add(plunger); } } diff --git a/test/game/components/spaceship_test.dart b/test/game/components/spaceship_test.dart index 7e16edd8..c7eb24b2 100644 --- a/test/game/components/spaceship_test.dart +++ b/test/game/components/spaceship_test.dart @@ -54,17 +54,6 @@ void main() { 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', () { @@ -87,17 +76,6 @@ void main() { 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 c145e2e4..b9e0ac7d 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -25,9 +25,7 @@ void main() { final walls = game.children.where( (component) => component is Wall && component is! BottomWall, ); - // TODO(allisonryan0002): expect 3 when launch track is added and - // temporary wall is removed. - expect(walls.length, 4); + expect(walls.length, 3); }, ); From 5b355ff406fca431dedde4eb44c48455187d8833 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Tue, 22 Mar 2022 12:57:11 +0000 Subject: [PATCH 06/10] refactor: used size instead of width and height (#69) --- lib/game/components/board.dart | 10 +++++----- lib/game/components/flipper.dart | 25 +++++++++++-------------- test/game/components/flipper_test.dart | 4 ++-- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index f7b80bd8..6107048e 100644 --- a/lib/game/components/board.dart +++ b/lib/game/components/board.dart @@ -94,7 +94,7 @@ class _BottomGroup extends Component { @override Future onLoad() async { - final spacing = this.spacing + Flipper.width / 2; + final spacing = this.spacing + Flipper.size.x / 2; final rightSide = _BottomGroupSide( side: BoardSide.right, position: position + Vector2(spacing, 0), @@ -135,15 +135,15 @@ class _BottomGroupSide extends Component { final baseboard = Baseboard(side: _side) ..initialPosition = _position + Vector2( - (Flipper.width * direction) - direction, - Flipper.height, + (Flipper.size.x * direction) - direction, + Flipper.size.y, ); final slingShot = SlingShot( side: _side, )..initialPosition = _position + Vector2( - (Flipper.width) * direction, - Flipper.height + SlingShot.size.y, + (Flipper.size.x) * direction, + Flipper.size.y + SlingShot.size.y, ); await addAll([flipper, baseboard, slingShot]); diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart index 77238acd..c4f18389 100644 --- a/lib/game/components/flipper.dart +++ b/lib/game/components/flipper.dart @@ -58,11 +58,8 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { /// Sprite is preloaded by [PinballGameAssetsX]. static const spritePath = 'components/flipper.png'; - /// The width of the [Flipper]. - static const width = 12.0; - - /// The height of the [Flipper]. - static const height = 2.8; + /// The size of the [Flipper]. + static final size = Vector2(12, 2.8); /// The speed required to move the [Flipper] to its highest position. /// @@ -97,7 +94,7 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { final sprite = await gameRef.loadSprite(spritePath); final spriteComponent = SpriteComponent( sprite: sprite, - size: Vector2(width, height), + size: size, anchor: Anchor.center, ); @@ -134,21 +131,21 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition { final fixturesDef = []; final isLeft = side.isLeft; - final bigCircleShape = CircleShape()..radius = height / 2; + final bigCircleShape = CircleShape()..radius = 1.75; bigCircleShape.position.setValues( isLeft - ? -(width / 2) + bigCircleShape.radius - : (width / 2) - bigCircleShape.radius, + ? -(size.x / 2) + bigCircleShape.radius + : (size.x / 2) - bigCircleShape.radius, 0, ); final bigCircleFixtureDef = FixtureDef(bigCircleShape); fixturesDef.add(bigCircleFixtureDef); - final smallCircleShape = CircleShape()..radius = bigCircleShape.radius / 2; + final smallCircleShape = CircleShape()..radius = 0.9; smallCircleShape.position.setValues( isLeft - ? (width / 2) - smallCircleShape.radius - : -(width / 2) + smallCircleShape.radius, + ? (size.x / 2) - smallCircleShape.radius + : -(size.x / 2) + smallCircleShape.radius, 0, ); final smallCircleFixtureDef = FixtureDef(smallCircleShape); @@ -227,8 +224,8 @@ class FlipperAnchor extends JointAnchor { }) { initialPosition = Vector2( flipper.side.isLeft - ? flipper.body.position.x - Flipper.width / 2 - : flipper.body.position.x + Flipper.width / 2, + ? flipper.body.position.x - Flipper.size.x / 2 + : flipper.body.position.x + Flipper.size.x / 2, flipper.body.position.y, ); } diff --git a/test/game/components/flipper_test.dart b/test/game/components/flipper_test.dart index e6e9ba23..64d2f77b 100644 --- a/test/game/components/flipper_test.dart +++ b/test/game/components/flipper_test.dart @@ -282,7 +282,7 @@ void main() { final flipperAnchor = FlipperAnchor(flipper: flipper); await game.ensureAdd(flipperAnchor); - expect(flipperAnchor.body.position.x, equals(-Flipper.width / 2)); + expect(flipperAnchor.body.position.x, equals(-Flipper.size.x / 2)); }, ); @@ -297,7 +297,7 @@ void main() { final flipperAnchor = FlipperAnchor(flipper: flipper); await game.ensureAdd(flipperAnchor); - expect(flipperAnchor.body.position.x, equals(Flipper.width / 2)); + expect(flipperAnchor.body.position.x, equals(Flipper.size.x / 2)); }, ); }); From 79bb95bef97f20525e7cb0d19345dab0ce8a3b46 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Tue, 22 Mar 2022 13:21:04 +0000 Subject: [PATCH 07/10] refactor: rename `SlingShot` to `Kicker` (#68) * refactor: renamed SlingShot to Kicker --- lib/game/components/board.dart | 10 +++---- lib/game/components/board_side.dart | 2 +- lib/game/components/components.dart | 2 +- .../{sling_shot.dart => kicker.dart} | 26 +++++++++---------- test/game/components/board_test.dart | 6 ++--- ...{sling_shot_test.dart => kicker_test.dart} | 26 +++++++++---------- 6 files changed, 36 insertions(+), 36 deletions(-) rename lib/game/components/{sling_shot.dart => kicker.dart} (86%) rename test/game/components/{sling_shot_test.dart => kicker_test.dart} (65%) diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index 6107048e..9c34a263 100644 --- a/lib/game/components/board.dart +++ b/lib/game/components/board.dart @@ -3,7 +3,7 @@ import 'package:pinball/game/game.dart'; /// {@template board} /// The main flat surface of the [PinballGame], where the [Flipper]s, -/// [RoundBumper]s, [SlingShot]s are arranged. +/// [RoundBumper]s, [Kicker]s are arranged. /// {entemplate} class Board extends Component { /// {@macro board} @@ -76,7 +76,7 @@ class _FlutterForest extends Component { /// {@template bottom_group} /// Grouping of the board's bottom [Component]s. /// -/// The [_BottomGroup] consists of[Flipper]s, [Baseboard]s and [SlingShot]s. +/// The [_BottomGroup] consists of[Flipper]s, [Baseboard]s and [Kicker]s. /// {@endtemplate} // TODO(alestiago): Consider renaming once entire Board is defined. class _BottomGroup extends Component { @@ -138,14 +138,14 @@ class _BottomGroupSide extends Component { (Flipper.size.x * direction) - direction, Flipper.size.y, ); - final slingShot = SlingShot( + final kicker = Kicker( side: _side, )..initialPosition = _position + Vector2( (Flipper.size.x) * direction, - Flipper.size.y + SlingShot.size.y, + Flipper.size.y + Kicker.size.y, ); - await addAll([flipper, baseboard, slingShot]); + await addAll([flipper, baseboard, kicker]); } } diff --git a/lib/game/components/board_side.dart b/lib/game/components/board_side.dart index f7587f47..2ef8d651 100644 --- a/lib/game/components/board_side.dart +++ b/lib/game/components/board_side.dart @@ -3,7 +3,7 @@ import 'package:pinball/game/game.dart'; /// Indicates a side of the board. /// /// Usually used to position or mirror elements of a [PinballGame]; such as a -/// [Flipper] or [SlingShot]. +/// [Flipper] or [Kicker]. enum BoardSide { /// The left side of the board. left, diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 84525166..a255e652 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -7,6 +7,7 @@ export 'flipper.dart'; export 'initial_position.dart'; export 'jetpack_ramp.dart'; export 'joint_anchor.dart'; +export 'kicker.dart'; export 'launcher_ramp.dart'; export 'layer.dart'; export 'pathway.dart'; @@ -14,6 +15,5 @@ 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/sling_shot.dart b/lib/game/components/kicker.dart similarity index 86% rename from lib/game/components/sling_shot.dart rename to lib/game/components/kicker.dart index 53160213..e4b2824d 100644 --- a/lib/game/components/sling_shot.dart +++ b/lib/game/components/kicker.dart @@ -6,15 +6,15 @@ import 'package:flutter/material.dart'; import 'package:geometry/geometry.dart' as geometry show centroid; import 'package:pinball/game/game.dart'; -/// {@template sling_shot} +/// {@template kicker} /// Triangular [BodyType.static] body that propels the [Ball] towards the /// opposite side. /// -/// [SlingShot]s are usually positioned above each [Flipper]. -/// {@endtemplate sling_shot} -class SlingShot extends BodyComponent with InitialPosition { - /// {@macro sling_shot} - SlingShot({ +/// [Kicker]s are usually positioned above each [Flipper]. +/// {@endtemplate kicker} +class Kicker extends BodyComponent with InitialPosition { + /// {@macro kicker} + Kicker({ required BoardSide side, }) : _side = side { // TODO(alestiago): Use sprite instead of color when provided. @@ -23,14 +23,14 @@ class SlingShot extends BodyComponent with InitialPosition { ..style = PaintingStyle.fill; } - /// Whether the [SlingShot] is on the left or right side of the board. + /// Whether the [Kicker] is on the left or right side of the board. /// - /// A [SlingShot] with [BoardSide.left] propels the [Ball] to the right, - /// whereas a [SlingShot] with [BoardSide.right] propels the [Ball] to the + /// A [Kicker] with [BoardSide.left] propels the [Ball] to the right, + /// whereas a [Kicker] with [BoardSide.right] propels the [Ball] to the /// left. final BoardSide _side; - /// The size of the [SlingShot] body. + /// The size of the [Kicker] body. // TODO(alestiago): Use size from PositionedBodyComponent instead, // once a sprite is given. static final Vector2 size = Vector2(4, 10); @@ -78,7 +78,7 @@ class SlingShot extends BodyComponent with InitialPosition { final bottomLineFixtureDef = FixtureDef(bottomEdge)..friction = 0; fixturesDefs.add(bottomLineFixtureDef); - final kickerEdge = EdgeShape() + final bouncyEdge = EdgeShape() ..set( upperCircle.position + Vector2( @@ -92,11 +92,11 @@ class SlingShot extends BodyComponent with InitialPosition { ), ); - final kickerFixtureDef = FixtureDef(kickerEdge) + final bouncyFixtureDef = FixtureDef(bouncyEdge) // TODO(alestiago): Play with restitution value once game is bundled. ..restitution = 10.0 ..friction = 0; - fixturesDefs.add(kickerFixtureDef); + fixturesDefs.add(bouncyFixtureDef); // TODO(alestiago): Evaluate if there is value on centering the fixtures. final centroid = geometry.centroid( diff --git a/test/game/components/board_test.dart b/test/game/components/board_test.dart index ccf599ec..1b87fd24 100644 --- a/test/game/components/board_test.dart +++ b/test/game/components/board_test.dart @@ -65,14 +65,14 @@ void main() { ); flameTester.test( - 'has two SlingShots', + 'has two Kickers', (game) async { final board = Board(size: Vector2.all(500)); await game.ready(); await game.ensureAdd(board); - final slingShots = board.findNestedChildren(); - expect(slingShots.length, equals(2)); + final kickers = board.findNestedChildren(); + expect(kickers.length, equals(2)); }, ); diff --git a/test/game/components/sling_shot_test.dart b/test/game/components/kicker_test.dart similarity index 65% rename from test/game/components/sling_shot_test.dart rename to test/game/components/kicker_test.dart index 2bfb2355..211ff8ad 100644 --- a/test/game/components/sling_shot_test.dart +++ b/test/game/components/kicker_test.dart @@ -6,43 +6,43 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; void main() { - group('SlingShot', () { + group('Kicker', () { // TODO(alestiago): Include golden tests for left and right. final flameTester = FlameTester(Forge2DGame.new); flameTester.test( 'loads correctly', (game) async { - final slingShot = SlingShot( + final kicker = Kicker( side: BoardSide.left, ); - await game.ensureAdd(slingShot); + await game.ensureAdd(kicker); - expect(game.contains(slingShot), isTrue); + expect(game.contains(kicker), isTrue); }, ); flameTester.test( 'body is static', (game) async { - final slingShot = SlingShot( + final kicker = Kicker( side: BoardSide.left, ); - await game.ensureAdd(slingShot); + await game.ensureAdd(kicker); - expect(slingShot.body.bodyType, equals(BodyType.static)); + expect(kicker.body.bodyType, equals(BodyType.static)); }, ); flameTester.test( 'has restitution', (game) async { - final slingShot = SlingShot( + final kicker = Kicker( side: BoardSide.left, ); - await game.ensureAdd(slingShot); + await game.ensureAdd(kicker); - final totalRestitution = slingShot.body.fixtures.fold( + final totalRestitution = kicker.body.fixtures.fold( 0, (total, fixture) => total + fixture.restitution, ); @@ -53,12 +53,12 @@ void main() { flameTester.test( 'has no friction', (game) async { - final slingShot = SlingShot( + final kicker = Kicker( side: BoardSide.left, ); - await game.ensureAdd(slingShot); + await game.ensureAdd(kicker); - final totalFriction = slingShot.body.fixtures.fold( + final totalFriction = kicker.body.fixtures.fold( 0, (total, fixture) => total + fixture.friction, ); From e0e8fabce5ea622207947e7e40ccfdf4c56fa2b8 Mon Sep 17 00:00:00 2001 From: Rui Miguel Alonso Date: Tue, 22 Mar 2022 14:51:04 +0100 Subject: [PATCH 08/10] feat: leaderboard bloc (#57) * feat: added bloc for request ranking * feat: added empty repository * test: tests for bloc * refactor: adapt bloc events and models to what leaderboard repository has * feat: extension to convert between CharacterTheme and CharacterType * doc: documented leaderboard bloc * refactor: merge with leaderboard_repository * doc: completed doc * chore: unused import, trailing comma * chore: removed ios files * Update lib/leaderboard/bloc/leaderboard_bloc.dart Co-authored-by: Erick * Update lib/leaderboard/bloc/leaderboard_bloc.dart Co-authored-by: Erick * refactor: remove props from abstract event class to force childs to implement it * Update lib/leaderboard/bloc/leaderboard_bloc.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_event.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_event.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_state.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_state.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_state.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_state.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_event.dart Co-authored-by: Alejandro Santiago * chore: ignore doc for file * chore: wrong reference at doc * Update lib/leaderboard/bloc/leaderboard_state.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_state.dart Co-authored-by: Alejandro Santiago * Update lib/leaderboard/bloc/leaderboard_state.dart Co-authored-by: Alejandro Santiago * refactor: leaderboard state initial method Co-authored-by: Erick Co-authored-by: Alejandro Santiago --- lib/leaderboard/bloc/leaderboard_bloc.dart | 64 +++++++ lib/leaderboard/bloc/leaderboard_event.dart | 36 ++++ lib/leaderboard/bloc/leaderboard_state.dart | 59 +++++++ lib/leaderboard/leaderboard.dart | 1 + .../bloc/leaderboard_bloc_test.dart | 166 ++++++++++++++++++ .../bloc/leaderboard_event_test.dart | 41 +++++ .../bloc/leaderboard_state_test.dart | 70 ++++++++ 7 files changed, 437 insertions(+) create mode 100644 lib/leaderboard/bloc/leaderboard_bloc.dart create mode 100644 lib/leaderboard/bloc/leaderboard_event.dart create mode 100644 lib/leaderboard/bloc/leaderboard_state.dart create mode 100644 lib/leaderboard/leaderboard.dart create mode 100644 test/leaderboard/bloc/leaderboard_bloc_test.dart create mode 100644 test/leaderboard/bloc/leaderboard_event_test.dart create mode 100644 test/leaderboard/bloc/leaderboard_state_test.dart diff --git a/lib/leaderboard/bloc/leaderboard_bloc.dart b/lib/leaderboard/bloc/leaderboard_bloc.dart new file mode 100644 index 00000000..6542548d --- /dev/null +++ b/lib/leaderboard/bloc/leaderboard_bloc.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; + +part 'leaderboard_event.dart'; +part 'leaderboard_state.dart'; + +/// {@template leaderboard_bloc} +/// Manages leaderboard events. +/// +/// Uses a [LeaderboardRepository] to request and update players participations. +/// {@endtemplate} +class LeaderboardBloc extends Bloc { + /// {@macro leaderboard_bloc} + LeaderboardBloc(this._leaderboardRepository) + : super(const LeaderboardState.initial()) { + on(_onTop10Fetched); + on(_onLeaderboardEntryAdded); + } + + final LeaderboardRepository _leaderboardRepository; + + Future _onTop10Fetched( + Top10Fetched event, + Emitter emit, + ) async { + emit(state.copyWith(status: LeaderboardStatus.loading)); + try { + final top10Leaderboard = + await _leaderboardRepository.fetchTop10Leaderboard(); + emit( + state.copyWith( + status: LeaderboardStatus.success, + leaderboard: top10Leaderboard, + ), + ); + } catch (error) { + emit(state.copyWith(status: LeaderboardStatus.error)); + addError(error); + } + } + + Future _onLeaderboardEntryAdded( + LeaderboardEntryAdded event, + Emitter emit, + ) async { + emit(state.copyWith(status: LeaderboardStatus.loading)); + try { + final ranking = + await _leaderboardRepository.addLeaderboardEntry(event.entry); + emit( + state.copyWith( + status: LeaderboardStatus.success, + ranking: ranking, + ), + ); + } catch (error) { + emit(state.copyWith(status: LeaderboardStatus.error)); + addError(error); + } + } +} diff --git a/lib/leaderboard/bloc/leaderboard_event.dart b/lib/leaderboard/bloc/leaderboard_event.dart new file mode 100644 index 00000000..34152163 --- /dev/null +++ b/lib/leaderboard/bloc/leaderboard_event.dart @@ -0,0 +1,36 @@ +part of 'leaderboard_bloc.dart'; + +/// {@template leaderboard_event} +/// Represents the events available for [LeaderboardBloc]. +/// {endtemplate} +abstract class LeaderboardEvent extends Equatable { + /// {@macro leaderboard_event} + const LeaderboardEvent(); +} + +/// {@template top_10_fetched} +/// Request the top 10 [LeaderboardEntry]s. +/// {endtemplate} +class Top10Fetched extends LeaderboardEvent { + /// {@macro top_10_fetched} + const Top10Fetched(); + + @override + List get props => []; +} + +/// {@template leaderboard_entry_added} +/// Writes a new [LeaderboardEntry]. +/// +/// Should be added when a player finishes a game. +/// {endtemplate} +class LeaderboardEntryAdded extends LeaderboardEvent { + /// {@macro leaderboard_entry_added} + const LeaderboardEntryAdded({required this.entry}); + + /// [LeaderboardEntry] to be written to the remote storage. + final LeaderboardEntry entry; + + @override + List get props => [entry]; +} diff --git a/lib/leaderboard/bloc/leaderboard_state.dart b/lib/leaderboard/bloc/leaderboard_state.dart new file mode 100644 index 00000000..20d68f0d --- /dev/null +++ b/lib/leaderboard/bloc/leaderboard_state.dart @@ -0,0 +1,59 @@ +// ignore_for_file: public_member_api_docs + +part of 'leaderboard_bloc.dart'; + +/// Defines the request status. +enum LeaderboardStatus { + /// Request is being loaded. + loading, + + /// Request was processed successfully and received a valid response. + success, + + /// Request was processed unsuccessfully and received an error. + error, +} + +/// {@template leaderboard_state} +/// Represents the state of the leaderboard. +/// {@endtemplate} +class LeaderboardState extends Equatable { + /// {@macro leaderboard_state} + const LeaderboardState({ + required this.status, + required this.ranking, + required this.leaderboard, + }); + + const LeaderboardState.initial() + : status = LeaderboardStatus.loading, + ranking = const LeaderboardRanking( + ranking: 0, + outOf: 0, + ), + leaderboard = const []; + + /// The current [LeaderboardStatus] of the state. + final LeaderboardStatus status; + + /// Rank of the current player. + final LeaderboardRanking ranking; + + /// List of top-ranked players. + final List leaderboard; + + @override + List get props => [status, ranking, leaderboard]; + + LeaderboardState copyWith({ + LeaderboardStatus? status, + LeaderboardRanking? ranking, + List? leaderboard, + }) { + return LeaderboardState( + status: status ?? this.status, + ranking: ranking ?? this.ranking, + leaderboard: leaderboard ?? this.leaderboard, + ); + } +} diff --git a/lib/leaderboard/leaderboard.dart b/lib/leaderboard/leaderboard.dart new file mode 100644 index 00000000..13d71e40 --- /dev/null +++ b/lib/leaderboard/leaderboard.dart @@ -0,0 +1 @@ +export 'bloc/leaderboard_bloc.dart'; diff --git a/test/leaderboard/bloc/leaderboard_bloc_test.dart b/test/leaderboard/bloc/leaderboard_bloc_test.dart new file mode 100644 index 00000000..c44f7d3a --- /dev/null +++ b/test/leaderboard/bloc/leaderboard_bloc_test.dart @@ -0,0 +1,166 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; + +class MockLeaderboardRepository extends Mock implements LeaderboardRepository {} + +void main() { + group('LeaderboardBloc', () { + late LeaderboardRepository leaderboardRepository; + + setUp(() { + leaderboardRepository = MockLeaderboardRepository(); + }); + + test('initial state has state loading no ranking and empty leaderboard', + () { + final bloc = LeaderboardBloc(leaderboardRepository); + expect(bloc.state.status, equals(LeaderboardStatus.loading)); + expect(bloc.state.ranking.ranking, equals(0)); + expect(bloc.state.ranking.outOf, equals(0)); + expect(bloc.state.leaderboard.isEmpty, isTrue); + }); + + group('Top10Fetched', () { + const top10Scores = [ + 2500, + 2200, + 2200, + 2000, + 1800, + 1400, + 1300, + 1000, + 600, + 300, + 100, + ]; + + final top10Leaderboard = top10Scores + .map( + (score) => LeaderboardEntry( + playerInitials: 'user$score', + score: score, + character: CharacterType.dash, + ), + ) + .toList(); + + blocTest( + 'emits [loading, success] statuses ' + 'when fetchTop10Leaderboard succeeds', + setUp: () { + when(() => leaderboardRepository.fetchTop10Leaderboard()).thenAnswer( + (_) async => top10Leaderboard, + ); + }, + build: () => LeaderboardBloc(leaderboardRepository), + act: (bloc) => bloc.add(Top10Fetched()), + expect: () => [ + LeaderboardState.initial(), + isA() + ..having( + (element) => element.status, + 'status', + equals(LeaderboardStatus.success), + ) + ..having( + (element) => element.leaderboard.length, + 'leaderboard', + equals(top10Leaderboard.length), + ) + ], + verify: (_) => + verify(() => leaderboardRepository.fetchTop10Leaderboard()) + .called(1), + ); + + blocTest( + 'emits [loading, error] statuses ' + 'when fetchTop10Leaderboard fails', + setUp: () { + when(() => leaderboardRepository.fetchTop10Leaderboard()).thenThrow( + Exception(), + ); + }, + build: () => LeaderboardBloc(leaderboardRepository), + act: (bloc) => bloc.add(Top10Fetched()), + expect: () => [ + LeaderboardState.initial(), + LeaderboardState.initial().copyWith(status: LeaderboardStatus.error), + ], + verify: (_) => + verify(() => leaderboardRepository.fetchTop10Leaderboard()) + .called(1), + errors: () => [isA()], + ); + }); + + group('LeaderboardEntryAdded', () { + final leaderboardEntry = LeaderboardEntry( + playerInitials: 'ABC', + score: 1500, + character: CharacterType.dash, + ); + + final ranking = LeaderboardRanking(ranking: 3, outOf: 4); + + blocTest( + 'emits [loading, success] statuses ' + 'when addLeaderboardEntry succeeds', + setUp: () { + when( + () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), + ).thenAnswer( + (_) async => ranking, + ); + }, + build: () => LeaderboardBloc(leaderboardRepository), + act: (bloc) => bloc.add(LeaderboardEntryAdded(entry: leaderboardEntry)), + expect: () => [ + LeaderboardState.initial(), + isA() + ..having( + (element) => element.status, + 'status', + equals(LeaderboardStatus.success), + ) + ..having( + (element) => element.ranking, + 'ranking', + equals(ranking), + ) + ], + verify: (_) => verify( + () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), + ).called(1), + ); + + blocTest( + 'emits [loading, error] statuses ' + 'when addLeaderboardEntry fails', + setUp: () { + when( + () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), + ).thenThrow( + Exception(), + ); + }, + build: () => LeaderboardBloc(leaderboardRepository), + act: (bloc) => bloc.add(LeaderboardEntryAdded(entry: leaderboardEntry)), + expect: () => [ + LeaderboardState.initial(), + LeaderboardState.initial().copyWith(status: LeaderboardStatus.error), + ], + verify: (_) => verify( + () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), + ).called(1), + errors: () => [isA()], + ); + }); + }); +} diff --git a/test/leaderboard/bloc/leaderboard_event_test.dart b/test/leaderboard/bloc/leaderboard_event_test.dart new file mode 100644 index 00000000..f74296af --- /dev/null +++ b/test/leaderboard/bloc/leaderboard_event_test.dart @@ -0,0 +1,41 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; + +void main() { + group('GameEvent', () { + group('Top10Fetched', () { + test('can be instantiated', () { + expect(const Top10Fetched(), isNotNull); + }); + + test('supports value equality', () { + expect( + Top10Fetched(), + equals(const Top10Fetched()), + ); + }); + }); + + group('LeaderboardEntryAdded', () { + const leaderboardEntry = LeaderboardEntry( + playerInitials: 'ABC', + score: 1500, + character: CharacterType.dash, + ); + + test('can be instantiated', () { + expect(const LeaderboardEntryAdded(entry: leaderboardEntry), isNotNull); + }); + + test('supports value equality', () { + expect( + LeaderboardEntryAdded(entry: leaderboardEntry), + equals(const LeaderboardEntryAdded(entry: leaderboardEntry)), + ); + }); + }); + }); +} diff --git a/test/leaderboard/bloc/leaderboard_state_test.dart b/test/leaderboard/bloc/leaderboard_state_test.dart new file mode 100644 index 00000000..6ff5df13 --- /dev/null +++ b/test/leaderboard/bloc/leaderboard_state_test.dart @@ -0,0 +1,70 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; + +void main() { + group('LeaderboardState', () { + test('supports value equality', () { + expect( + LeaderboardState.initial(), + equals( + LeaderboardState.initial(), + ), + ); + }); + + group('constructor', () { + test('can be instantiated', () { + expect( + LeaderboardState.initial(), + isNotNull, + ); + }); + }); + + group('copyWith', () { + const leaderboardEntry = LeaderboardEntry( + playerInitials: 'ABC', + score: 1500, + character: CharacterType.dash, + ); + + test( + 'copies correctly ' + 'when no argument specified', + () { + const leaderboardState = LeaderboardState.initial(); + expect( + leaderboardState.copyWith(), + equals(leaderboardState), + ); + }, + ); + + test( + 'copies correctly ' + 'when all arguments specified', + () { + const leaderboardState = LeaderboardState.initial(); + final otherLeaderboardState = LeaderboardState( + status: LeaderboardStatus.success, + ranking: LeaderboardRanking(ranking: 0, outOf: 0), + leaderboard: const [leaderboardEntry], + ); + expect(leaderboardState, isNot(equals(otherLeaderboardState))); + + expect( + leaderboardState.copyWith( + status: otherLeaderboardState.status, + ranking: otherLeaderboardState.ranking, + leaderboard: otherLeaderboardState.leaderboard, + ), + equals(otherLeaderboardState), + ); + }, + ); + }); + }); +} From 4af09b64fa871f2c962deb43d31918e3bdfdcedd Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Tue, 22 Mar 2022 15:58:30 +0000 Subject: [PATCH 09/10] chore: bumped flame versions (#71) * chore: increased version * fix: changes due to versions --- lib/game/components/flipper.dart | 1 - lib/game/components/plunger.dart | 2 +- pubspec.lock | 8 ++++---- pubspec.yaml | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart index c4f18389..cf3fed4f 100644 --- a/lib/game/components/flipper.dart +++ b/lib/game/components/flipper.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flame/components.dart'; -import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/game/components/plunger.dart b/lib/game/components/plunger.dart index 61abaf1d..934cd8ac 100644 --- a/lib/game/components/plunger.dart +++ b/lib/game/components/plunger.dart @@ -1,4 +1,4 @@ -import 'package:flame/input.dart'; +import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/services.dart'; import 'package:pinball/game/game.dart'; diff --git a/pubspec.lock b/pubspec.lock index 71647cae..ac5fa36d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -182,21 +182,21 @@ packages: name: flame url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-releasecandidate.5" + version: "1.1.0-releasecandidate.6" flame_bloc: dependency: "direct main" description: name: flame_bloc url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-releasecandidate.5" + version: "1.2.0-releasecandidate.6" flame_forge2d: dependency: "direct main" description: name: flame_forge2d url: "https://pub.dartlang.org" source: hosted - version: "0.9.0-releasecandidate.5" + version: "0.9.0-releasecandidate.6" flame_test: dependency: "direct dev" description: @@ -237,7 +237,7 @@ packages: name: forge2d url: "https://pub.dartlang.org" source: hosted - version: "0.8.2" + version: "0.9.0" frontend_server_client: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 35d8190f..25c8fbb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,9 +10,9 @@ dependencies: bloc: ^8.0.2 cloud_firestore: ^3.1.10 equatable: ^2.0.3 - flame: ^1.1.0-releasecandidate.5 - flame_bloc: ^1.2.0-releasecandidate.5 - flame_forge2d: ^0.9.0-releasecandidate.5 + flame: ^1.1.0-releasecandidate.6 + flame_bloc: ^1.2.0-releasecandidate.6 + flame_forge2d: ^0.9.0-releasecandidate.6 flutter: sdk: flutter flutter_bloc: ^8.0.1 From a111fd417edbae19f3a60605e66e831ab511fd25 Mon Sep 17 00:00:00 2001 From: Erick Date: Tue, 22 Mar 2022 13:54:06 -0300 Subject: [PATCH 10/10] feat: fixed positioning (#70) * feat: game uses fixed positioning now * feat: fixed positioning * feat: pr suggestion * lint --- lib/game/components/board.dart | 12 ++--- lib/game/components/jetpack_ramp.dart | 39 ++++++++------ lib/game/components/launcher_ramp.dart | 25 ++++----- lib/game/components/pathway.dart | 5 +- lib/game/components/spaceship.dart | 5 +- lib/game/components/wall.dart | 18 +++---- lib/game/pinball_game.dart | 70 +++++++++++++------------- test/game/components/board_test.dart | 12 ++--- 8 files changed, 98 insertions(+), 88 deletions(-) diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index 9c34a263..5bd4d92b 100644 --- a/lib/game/components/board.dart +++ b/lib/game/components/board.dart @@ -7,25 +7,23 @@ import 'package:pinball/game/game.dart'; /// {entemplate} class Board extends Component { /// {@macro board} - Board({required Vector2 size}) : _size = size; - - final Vector2 _size; + Board(); @override Future onLoad() async { // TODO(alestiago): adjust positioning once sprites are added. final bottomGroup = _BottomGroup( position: Vector2( - _size.x / 2, - _size.y / 1.25, + PinballGame.boardBounds.center.dx, + PinballGame.boardBounds.bottom + 10, ), spacing: 2, ); final dashForest = _FlutterForest( position: Vector2( - _size.x / 1.25, - _size.y / 4.25, + PinballGame.boardBounds.right - 20, + PinballGame.boardBounds.top - 20, ), ); diff --git a/lib/game/components/jetpack_ramp.dart b/lib/game/components/jetpack_ramp.dart index 985c8f7d..aa5a2d3d 100644 --- a/lib/game/components/jetpack_ramp.dart +++ b/lib/game/components/jetpack_ramp.dart @@ -30,18 +30,23 @@ class JetpackRamp extends Component with HasGameRef { // TODO(ruialonso): Use a bezier curve once control points are defined. color: const Color.fromARGB(255, 8, 218, 241), center: position, - width: 62, - radius: 200, + width: 5, + radius: 18, angle: math.pi, + rotation: math.pi, + )..layer = layer; + + final leftOpening = _JetpackRampOpening( + outsideLayer: Layer.spaceship, + rotation: math.pi, ) - ..initialPosition = position - ..layer = layer; - final leftOpening = _JetpackRampOpening(outsideLayer: Layer.spaceship) - ..initialPosition = position + Vector2(-27.6, 25.3) + ..initialPosition = position - Vector2(2, 22) ..layer = Layer.jetpack; - final rightOpening = _JetpackRampOpening() - ..initialPosition = position + Vector2(-10.6, 25.3) + final rightOpening = _JetpackRampOpening( + rotation: math.pi, + ) + ..initialPosition = position - Vector2(-13, 22) ..layer = Layer.opening; await addAll([ @@ -60,20 +65,26 @@ class _JetpackRampOpening extends RampOpening { /// {@macro jetpack_ramp_opening} _JetpackRampOpening({ Layer? outsideLayer, - }) : super( + required double rotation, + }) : _rotation = rotation, + super( pathwayLayer: Layer.jetpack, outsideLayer: outsideLayer, orientation: RampOrientation.down, ); - // TODO(ruialonso): Avoid magic number 2, should be proportional to + final double _rotation; + + // TODO(ruialonso): Avoid magic number 3, should be propotional to // [JetpackRamp]. - static const _size = 2; + static final Vector2 _size = Vector2(3, .1); @override Shape get shape => PolygonShape() - ..setAsEdge( - Vector2(initialPosition.x - _size, initialPosition.y), - Vector2(initialPosition.x + _size, initialPosition.y), + ..setAsBox( + _size.x, + _size.y, + initialPosition, + _rotation, ); } diff --git a/lib/game/components/launcher_ramp.dart b/lib/game/components/launcher_ramp.dart index 21d4d666..5fdabcdb 100644 --- a/lib/game/components/launcher_ramp.dart +++ b/lib/game/components/launcher_ramp.dart @@ -28,26 +28,27 @@ class LauncherRamp extends Component with HasGameRef { final straightPath = Pathway.straight( color: const Color.fromARGB(255, 34, 255, 0), - start: Vector2(0, 0), - end: Vector2(0, 700), - width: 80, + start: Vector2(position.x, position.y), + end: Vector2(position.x, 74), + width: 5, ) ..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; + center: position + Vector2(-1, 68), + radius: 20, + angle: 8 * math.pi / 9, + width: 5, + rotation: math.pi, + )..layer = layer; + final leftOpening = _LauncherRampOpening(rotation: 13 * math.pi / 180) - ..initialPosition = position + Vector2(-72.5, 12) + ..initialPosition = position + Vector2(1, 49) ..layer = Layer.opening; final rightOpening = _LauncherRampOpening(rotation: 0) - ..initialPosition = position + Vector2(-46.8, 17) + ..initialPosition = position + Vector2(-16, 46) ..layer = Layer.opening; await addAll([ diff --git a/lib/game/components/pathway.dart b/lib/game/components/pathway.dart index 414442d3..0c29dd7b 100644 --- a/lib/game/components/pathway.dart +++ b/lib/game/components/pathway.dart @@ -150,10 +150,7 @@ class Pathway extends BodyComponent with InitialPosition, Layered { final fixturesDef = []; for (final path in _paths) { - final chain = ChainShape() - ..createChain( - path.map(gameRef.screenToWorld).toList(), - ); + final chain = ChainShape()..createChain(path); fixturesDef.add(FixtureDef(chain)); } diff --git a/lib/game/components/spaceship.dart b/lib/game/components/spaceship.dart index f934d943..d933a79f 100644 --- a/lib/game/components/spaceship.dart +++ b/lib/game/components/spaceship.dart @@ -15,7 +15,10 @@ class Spaceship extends Forge2DBlueprint { @override void build() { - final position = Vector2(30, -50); + final position = Vector2( + PinballGame.boardBounds.left + radius + 0.5, + PinballGame.boardBounds.center.dy + 34, + ); addAllContactCallback([ SpaceshipHoleBallContactCallback(), diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart index 017f8c4d..62f9033f 100644 --- a/lib/game/components/wall.dart +++ b/lib/game/components/wall.dart @@ -1,7 +1,9 @@ // ignore_for_file: avoid_renaming_method_parameters +import 'package:flame/extensions.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/components/components.dart'; +import 'package:pinball/game/pinball_game.dart'; /// {@template wall} /// A continuous generic and [BodyType.static] barrier that divides a game area. @@ -39,15 +41,16 @@ class Wall extends BodyComponent { /// Create top, left, and right [Wall]s for the game board. List createBoundaries(Forge2DGame game) { - final topLeft = Vector2.zero(); - final bottomRight = game.screenToWorld(game.camera.viewport.effectiveSize); + final topLeft = PinballGame.boardBounds.topLeft.toVector2(); + final bottomRight = PinballGame.boardBounds.bottomRight.toVector2(); + final topRight = Vector2(bottomRight.x, topLeft.y); final bottomLeft = Vector2(topLeft.x, bottomRight.y); return [ Wall(start: topLeft, end: topRight), Wall(start: topRight, end: bottomRight), - Wall(start: bottomLeft, end: topLeft), + Wall(start: topLeft, end: bottomLeft), ]; } @@ -59,13 +62,10 @@ List createBoundaries(Forge2DGame game) { /// {@endtemplate} class BottomWall extends Wall { /// {@macro bottom_wall} - BottomWall(Forge2DGame game) + BottomWall() : super( - start: game.screenToWorld(game.camera.viewport.effectiveSize), - end: Vector2( - 0, - game.screenToWorld(game.camera.viewport.effectiveSize).y, - ), + start: PinballGame.boardBounds.bottomLeft.toVector2(), + end: PinballGame.boardBounds.bottomRight.toVector2(), ); } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 86bceef6..c23fa095 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -1,6 +1,7 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; +import 'package:flame/extensions.dart'; import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; @@ -16,6 +17,13 @@ class PinballGame extends Forge2DGame late final Plunger plunger; + static final boardSize = Vector2(72, 128); + static final boardBounds = Rect.fromCenter( + center: Offset.zero, + width: boardSize.x, + height: -boardSize.y, + ); + @override void onAttach() { super.onAttach(); @@ -27,11 +35,16 @@ class PinballGame extends Forge2DGame _addContactCallbacks(); await _addGameBoundaries(); - unawaited(_addBoard()); + unawaited(add(Board())); unawaited(_addPlunger()); unawaited(_addBonusWord()); unawaited(_addPaths()); unawaited(addFromBlueprint(Spaceship())); + + // Fix camera on the center of the board size + camera + ..followVector2(screenToWorld(boardSize / 2)) + ..zoom = size.y / 14; } void _addContactCallbacks() { @@ -41,44 +54,27 @@ class PinballGame extends Forge2DGame } Future _addGameBoundaries() async { - await add(BottomWall(this)); + await add(BottomWall()); createBoundaries(this).forEach(add); } - Future _addBoard() async { - final board = Board( - size: screenToWorld( - Vector2( - camera.viewport.effectiveSize.x, - camera.viewport.effectiveSize.y, - ), - ), - ); - await add(board); - } - Future _addPlunger() async { - plunger = Plunger( - compressionDistance: camera.viewport.effectiveSize.y / 12, - ); - plunger.initialPosition = screenToWorld( - Vector2( - camera.viewport.effectiveSize.x / 2 + 450, - camera.viewport.effectiveSize.y - plunger.compressionDistance, - ), - ); + plunger = Plunger(compressionDistance: 2); + plunger.initialPosition = boardBounds.bottomRight.toVector2() - + Vector2( + 8, + -10, + ); await add(plunger); } Future _addBonusWord() async { await add( BonusWord( - position: screenToWorld( - Vector2( - camera.viewport.effectiveSize.x / 2, - camera.viewport.effectiveSize.y - 50, - ), + position: Vector2( + boardBounds.center.dx, + boardBounds.bottom + 10, ), ), ); @@ -86,18 +82,22 @@ class PinballGame extends Forge2DGame Future _addPaths() async { final jetpackRamp = JetpackRamp( - position: Vector2(42.6, -45), + position: Vector2( + PinballGame.boardBounds.left + 25, + PinballGame.boardBounds.top - 20, + ), ); final launcherRamp = LauncherRamp( - position: screenToWorld( - Vector2( - camera.viewport.effectiveSize.x / 2 + 400, - camera.viewport.effectiveSize.y / 2 - 330, - ), + position: Vector2( + PinballGame.boardBounds.right - 23, + PinballGame.boardBounds.bottom + 40, ), ); - await addAll([jetpackRamp, launcherRamp]); + await addAll([ + jetpackRamp, + launcherRamp, + ]); } void spawnBall() { diff --git a/test/game/components/board_test.dart b/test/game/components/board_test.dart index 1b87fd24..04847dec 100644 --- a/test/game/components/board_test.dart +++ b/test/game/components/board_test.dart @@ -15,7 +15,7 @@ void main() { flameTester.test( 'loads correctly', (game) async { - final board = Board(size: Vector2.all(500)); + final board = Board(); await game.ready(); await game.ensureAdd(board); @@ -27,7 +27,7 @@ void main() { flameTester.test( 'has one left flipper', (game) async { - final board = Board(size: Vector2.all(500)); + final board = Board(); await game.ready(); await game.ensureAdd(board); @@ -41,7 +41,7 @@ void main() { flameTester.test( 'has one right flipper', (game) async { - final board = Board(size: Vector2.all(500)); + final board = Board(); await game.ready(); await game.ensureAdd(board); @@ -55,7 +55,7 @@ void main() { flameTester.test( 'has two Baseboards', (game) async { - final board = Board(size: Vector2.all(500)); + final board = Board(); await game.ready(); await game.ensureAdd(board); @@ -67,7 +67,7 @@ void main() { flameTester.test( 'has two Kickers', (game) async { - final board = Board(size: Vector2.all(500)); + final board = Board(); await game.ready(); await game.ensureAdd(board); @@ -80,7 +80,7 @@ void main() { 'has three RoundBumpers', (game) async { // TODO(alestiago): change to [NestBumpers] once provided. - final board = Board(size: Vector2.all(500)); + final board = Board(); await game.ready(); await game.ensureAdd(board);