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