From ddec3c0f44ce66a67c200b9cd375dbf8f312b3e2 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Fri, 11 Mar 2022 16:34:58 +0000 Subject: [PATCH 1/3] feat: implemented Flipper grouping (#32) * feat: started implementing FlipperGroup * refactor: simplified Flipper logic * refactor: used extension instead of condition * docs: used "loads" over "adds" * feat: used size rather than width and height * refactor: removed unecessary mixin * feat: reorder methods * refactor: removed _joint over joint * docs: fixed macro typo * feat: unawait add operation * feat: included tests * refactor: remove Flutter dep from geometry (#27) * fix: removed flutter dependency * test: fixed tests for assertions * test: check assertion with isA * ci: added geometry workflow file * refactor: changed flame dep to vector_math for vector2 * fix: changed import for vector to vector_math_64 * Update .github/workflows/geometry.yaml Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * chore: rename pinball game test extension (#33) * chore: rename pinball game test extension * refactor: initial to create * docs: small change * chore: removed unecessary end callback (#30) * feat: adding ball spawning upon click on debug mode (#28) * feat: adding ball spawming upon click on debug mode * PR suggestions * fix: coverage * fix: rebase * feat: rebase fixes * feat: adding bonus logic to the game bloc (#24) * feat: adding bonus logic to the game bloc * feat: PR suggestions * Apply suggestions from code review Co-authored-by: Alejandro Santiago * feat: pr suggestions * chore: main rebase * feat: pr suggestions * Apply suggestions from code review Co-authored-by: Alejandro Santiago * feat: pr suggestion * feat: pr suggestions * feat: pr suggestions * Apply suggestions from code review Co-authored-by: Alejandro Santiago * feat: pr suggestions Co-authored-by: Alejandro Santiago * feat: add plunger to board (#25) * feat: add plunger to board * refactor: leave spawn ball synchronous * fix: ball test * refactor: position ball internally * fix: ball position test * refactor: use joint specific anchor * refactor: remove ballSize * fix: plunger position * refactor: use relative positioning Co-authored-by: Alejandro Santiago * refactor: added missing white space Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * refactor: adding missing white space Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * refactor: renamed test * refactor: used ! instead of ? * chore: rebasing * refactor: simplified Flipper logic * refactor: used extension instead of condition * docs: used "loads" over "adds" * feat: used size rather than width and height * refactor: removed unecessary mixin * feat: reorder methods * refactor: removed _joint over joint * docs: fixed macro typo * chore: rebasing * refactor: added missing white space * refactor: used ! instead of ? Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Co-authored-by: Erick --- lib/game/components/flipper.dart | 118 ++++++++++++++++++------- lib/game/pinball_game.dart | 83 ++++------------- test/game/components/flipper_test.dart | 102 +++++++++++++++++++++ test/game/pinball_game_test.dart | 101 +++++++++------------ 4 files changed, 243 insertions(+), 161 deletions(-) diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart index a14679f0..6c184383 100644 --- a/lib/game/components/flipper.dart +++ b/lib/game/components/flipper.dart @@ -1,12 +1,45 @@ import 'dart:async'; import 'dart:math' as math; -import 'package:flame/components.dart' show SpriteComponent; +import 'package:flame/components.dart' show PositionComponent, SpriteComponent; import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/services.dart'; import 'package:pinball/game/game.dart'; +/// {@template flipper_group} +/// Loads a [Flipper.right] and a [Flipper.left]. +/// {@endtemplate} +class FlipperGroup extends PositionComponent { + /// {@macro flipper_group} + FlipperGroup({ + required Vector2 position, + required this.spacing, + }) : super(position: position); + + /// The amount of space between the [Flipper.right] and [Flipper.left]. + final double spacing; + + @override + Future onLoad() async { + final leftFlipper = Flipper.left( + position: Vector2( + position.x - (Flipper.width / 2) - (spacing / 2), + position.y, + ), + ); + await add(leftFlipper); + + final rightFlipper = Flipper.right( + position: Vector2( + position.x + (Flipper.width / 2) + (spacing / 2), + position.y, + ), + ); + await add(rightFlipper); + } +} + /// {@template flipper} /// A bat, typically found in pairs at the bottom of the board. /// @@ -76,20 +109,6 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { /// [onKeyEvent] method listens to when one of these keys is pressed. final List _keys; - @override - Future onLoad() async { - await super.onLoad(); - final sprite = await gameRef.loadSprite(spritePath); - positionComponent = SpriteComponent( - sprite: sprite, - size: size, - ); - - if (side == BoardSide.right) { - positionComponent?.flipHorizontally(); - } - } - /// Applies downward linear velocity to the [Flipper], moving it to its /// resting position. void _moveDown() { @@ -102,15 +121,50 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { body.linearVelocity = Vector2(0, _speed); } + /// Loads the sprite that renders with the [Flipper]. + Future _loadSprite() async { + final sprite = await gameRef.loadSprite(spritePath); + positionComponent = SpriteComponent( + sprite: sprite, + size: size, + ); + + if (side.isRight) { + positionComponent!.flipHorizontally(); + } + } + + /// Anchors the [Flipper] to the [RevoluteJoint] that controls its arc motion. + Future _anchorToJoint() async { + final anchor = FlipperAnchor(flipper: this); + await add(anchor); + + final jointDef = FlipperAnchorRevoluteJointDef( + flipper: this, + anchor: anchor, + ); + // TODO(alestiago): Remove casting once the following is closed: + // https://github.com/flame-engine/forge2d/issues/36 + final joint = world.createJoint(jointDef) as RevoluteJoint; + + // FIXME(erickzanardo): when mounted the initial position is not fully + // reached. + unawaited( + mounted.whenComplete( + () => FlipperAnchorRevoluteJointDef.unlock(joint, side), + ), + ); + } + List _createFixtureDefs() { final fixtures = []; final isLeft = side.isLeft; - final bigCircleShape = CircleShape()..radius = height / 2; + final bigCircleShape = CircleShape()..radius = size.y / 2; 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); @@ -119,8 +173,8 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { final smallCircleShape = CircleShape()..radius = bigCircleShape.radius / 2; 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); @@ -148,6 +202,15 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { return fixtures; } + @override + Future onLoad() async { + await super.onLoad(); + await Future.wait([ + _loadSprite(), + _anchorToJoint(), + ]); + } + @override Body createBody() { final bodyDef = BodyDef() @@ -161,17 +224,6 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { return body; } - // TODO(erickzanardo): Remove this once the issue is solved: - // https://github.com/flame-engine/flame/issues/1417 - // ignore: public_member_api_docs - final Completer hasMounted = Completer(); - - @override - void onMount() { - super.onMount(); - hasMounted.complete(); - } - @override bool onKeyEvent( RawKeyEvent event, @@ -204,8 +256,8 @@ class FlipperAnchor extends Anchor { }) : super( position: 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/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index f4ff6757..fbb68e63 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -26,7 +26,6 @@ class PinballGame extends Forge2DGame _addContactCallbacks(); await _addGameBoundaries(); - unawaited(_addFlippers()); unawaited(_addPlunger()); // Corner wall above plunger so the ball deflects into the rest of the @@ -48,6 +47,22 @@ class PinballGame extends Forge2DGame ), ), ); + + final flippersPosition = screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2, + camera.viewport.effectiveSize.y / 1.1, + ), + ); + + unawaited( + add( + FlipperGroup( + position: flippersPosition, + spacing: 2, + ), + ), + ); } void spawnBall() { @@ -64,72 +79,6 @@ class PinballGame extends Forge2DGame createBoundaries(this).forEach(add); } - Future _addFlippers() async { - final flippersPosition = screenToWorld( - Vector2( - camera.viewport.effectiveSize.x / 2, - camera.viewport.effectiveSize.y / 1.1, - ), - ); - const spaceBetweenFlippers = 2; - final leftFlipper = Flipper.left( - position: Vector2( - flippersPosition.x - (Flipper.width / 2) - (spaceBetweenFlippers / 2), - flippersPosition.y, - ), - ); - await add(leftFlipper); - final leftFlipperAnchor = FlipperAnchor(flipper: leftFlipper); - await add(leftFlipperAnchor); - final leftFlipperRevoluteJointDef = FlipperAnchorRevoluteJointDef( - flipper: leftFlipper, - anchor: leftFlipperAnchor, - ); - // TODO(alestiago): Remove casting once the following is closed: - // https://github.com/flame-engine/forge2d/issues/36 - final leftFlipperRevoluteJoint = - world.createJoint(leftFlipperRevoluteJointDef) as RevoluteJoint; - - final rightFlipper = Flipper.right( - position: Vector2( - flippersPosition.x + (Flipper.width / 2) + (spaceBetweenFlippers / 2), - flippersPosition.y, - ), - ); - await add(rightFlipper); - final rightFlipperAnchor = FlipperAnchor(flipper: rightFlipper); - await add(rightFlipperAnchor); - final rightFlipperRevoluteJointDef = FlipperAnchorRevoluteJointDef( - flipper: rightFlipper, - anchor: rightFlipperAnchor, - ); - // TODO(alestiago): Remove casting once the following is closed: - // https://github.com/flame-engine/forge2d/issues/36 - final rightFlipperRevoluteJoint = - world.createJoint(rightFlipperRevoluteJointDef) as RevoluteJoint; - - // TODO(erickzanardo): Clean this once the issue is solved: - // https://github.com/flame-engine/flame/issues/1417 - // FIXME(erickzanardo): when mounted the initial position is not fully - // reached. - unawaited( - leftFlipper.hasMounted.future.whenComplete( - () => FlipperAnchorRevoluteJointDef.unlock( - leftFlipperRevoluteJoint, - leftFlipper.side, - ), - ), - ); - unawaited( - rightFlipper.hasMounted.future.whenComplete( - () => FlipperAnchorRevoluteJointDef.unlock( - rightFlipperRevoluteJoint, - rightFlipper.side, - ), - ), - ); - } - Future _addPlunger() async { late PlungerAnchor plungerAnchor; final compressionDistance = camera.viewport.effectiveSize.y / 12; diff --git a/test/game/components/flipper_test.dart b/test/game/components/flipper_test.dart index 2a92f082..070265bc 100644 --- a/test/game/components/flipper_test.dart +++ b/test/game/components/flipper_test.dart @@ -2,6 +2,7 @@ import 'dart:collection'; +import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/services.dart'; @@ -13,6 +14,105 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); final flameTester = FlameTester(PinballGameTest.create); + + group('FlipperGroup', () { + flameTester.test( + 'loads correctly', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect(game.contains(flipperGroup), isTrue); + }, + ); + + group('constructor', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final flipperGroup = FlipperGroup( + position: position, + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect(flipperGroup.position, equals(position)); + }, + ); + }); + + group('children', () { + bool Function(Component) flipperSelector(BoardSide side) => + (component) => component is Flipper && component.side == side; + + flameTester.test( + 'has only one left Flipper', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect( + () => flipperGroup.children.singleWhere( + flipperSelector(BoardSide.left), + ), + returnsNormally, + ); + }, + ); + + flameTester.test( + 'has only one right Flipper', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect( + () => flipperGroup.children.singleWhere( + flipperSelector(BoardSide.right), + ), + returnsNormally, + ); + }, + ); + + flameTester.test( + 'spaced correctly', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 2, + ); + await game.ready(); + await game.ensureAdd(flipperGroup); + + final leftFlipper = flipperGroup.children.singleWhere( + flipperSelector(BoardSide.left), + ) as Flipper; + final rightFlipper = flipperGroup.children.singleWhere( + flipperSelector(BoardSide.right), + ) as Flipper; + + expect( + leftFlipper.body.position.x + + leftFlipper.size.x + + flipperGroup.spacing, + equals(rightFlipper.body.position.x), + ); + }, + ); + }); + }); + group( 'Flipper', () { @@ -21,9 +121,11 @@ void main() { (game) async { final leftFlipper = Flipper.left(position: Vector2.zero()); final rightFlipper = Flipper.right(position: Vector2.zero()); + await game.ready(); await game.ensureAddAll([leftFlipper, rightFlipper]); expect(game.contains(leftFlipper), isTrue); + expect(game.contains(rightFlipper), isTrue); }, ); diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index d7db79e2..faa55d11 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -18,72 +18,40 @@ void main() { // [BallScorePointsCallback] once the following issue is resolved: // https://github.com/flame-engine/flame/issues/1416 group('components', () { - group('Walls', () { - flameTester.test( - 'has three Walls', - (game) async { - await game.ready(); - final walls = game.children - .where( - (component) => component is Wall && component is! BottomWall, - ) - .toList(); - // TODO(allisonryan0002): expect 3 when launch track is added and - // temporary wall is removed. - expect(walls.length, 4); - }, - ); + bool Function(Component) componentSelector() => + (component) => component is T; - flameTester.test( - 'has only one BottomWall', - (game) async { - await game.ready(); - - expect( - () => game.children.singleWhere( - (component) => component is BottomWall, - ), - returnsNormally, - ); - }, - ); - }); + flameTester.test( + 'has three Walls', + (game) async { + await game.ready(); + final walls = game.children + .where( + (component) => component is Wall && component is! BottomWall, + ) + .toList(); + // TODO(allisonryan0002): expect 3 when launch track is added and + // temporary wall is removed. + expect(walls.length, 4); + }, + ); - group('Flippers', () { - bool Function(Component) flipperSelector(BoardSide side) => - (component) => component is Flipper && component.side == side; - - flameTester.test( - 'has only one left Flipper', - (game) async { - await game.ready(); - - expect( - () => game.children.singleWhere( - flipperSelector(BoardSide.left), - ), - returnsNormally, - ); - }, - ); + flameTester.test( + 'has only one BottomWall', + (game) async { + await game.ready(); - flameTester.test( - 'has only one right Flipper', - (game) async { - await game.ready(); - - expect( - () => game.children.singleWhere( - flipperSelector(BoardSide.right), - ), - returnsNormally, - ); - }, - ); - }); + expect( + () => game.children.singleWhere( + componentSelector(), + ), + returnsNormally, + ); + }, + ); flameTester.test( - 'Plunger has only one Plunger', + 'has only one Plunger', (game) async { await game.ready(); @@ -95,6 +63,17 @@ void main() { ); }, ); + + flameTester.test('has only one FlipperGroup', (game) async { + await game.ready(); + + expect( + () => game.children.singleWhere( + (component) => component is FlipperGroup, + ), + returnsNormally, + ); + }); }); debugModeFlameTester.test('adds a ball on tap up', (game) async { From f6f171b7c7f660cbd8f4c12a61c2752ebef3084a Mon Sep 17 00:00:00 2001 From: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Date: Fri, 11 Mar 2022 14:26:59 -0600 Subject: [PATCH 2/3] feat: add baseboards (#34) * feat: add baseboards * docs: baseboard * fix: test extension * chore: simplify rectangle * refactor: PR suggestions --- lib/game/components/baseboard.dart | 91 ++++++++++++++++++++++++ lib/game/components/components.dart | 1 + lib/game/pinball_game.dart | 26 +++++++ test/game/components/baseboard_test.dart | 76 ++++++++++++++++++++ test/game/pinball_game_test.dart | 9 +++ 5 files changed, 203 insertions(+) create mode 100644 lib/game/components/baseboard.dart create mode 100644 test/game/components/baseboard_test.dart diff --git a/lib/game/components/baseboard.dart b/lib/game/components/baseboard.dart new file mode 100644 index 00000000..9153d4f3 --- /dev/null +++ b/lib/game/components/baseboard.dart @@ -0,0 +1,91 @@ +import 'dart:math' as math; + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template baseboard} +/// Straight, angled board piece to corral the [Ball] towards the [Flipper]s. +/// {@endtemplate} +class Baseboard extends BodyComponent { + /// {@macro baseboard} + Baseboard._({ + required Vector2 position, + required BoardSide side, + }) : _position = position, + _side = side; + + /// A left positioned [Baseboard]. + Baseboard.left({ + required Vector2 position, + }) : this._( + position: position, + side: BoardSide.left, + ); + + /// A right positioned [Baseboard]. + Baseboard.right({ + required Vector2 position, + }) : this._( + position: position, + side: BoardSide.right, + ); + + /// The width of the [Baseboard]. + static const width = 10.0; + + /// The height of the [Baseboard]. + static const height = 2.0; + + /// The position of the [Baseboard] body. + final Vector2 _position; + + /// Whether the [Baseboard] is on the left or right side of the board. + final BoardSide _side; + + List _createFixtureDefs() { + final fixtures = []; + + final circleShape1 = CircleShape()..radius = Baseboard.height / 2; + circleShape1.position.setValues( + -(Baseboard.width / 2) + circleShape1.radius, + 0, + ); + final circle1FixtureDef = FixtureDef(circleShape1); + fixtures.add(circle1FixtureDef); + + final circleShape2 = CircleShape()..radius = Baseboard.height / 2; + circleShape2.position.setValues( + (Baseboard.width / 2) - circleShape2.radius, + 0, + ); + final circle2FixtureDef = FixtureDef(circleShape2); + fixtures.add(circle2FixtureDef); + + final rectangle = PolygonShape() + ..setAsBoxXY( + (Baseboard.width - Baseboard.height) / 2, + Baseboard.height / 2, + ); + final rectangleFixtureDef = FixtureDef(rectangle); + fixtures.add(rectangleFixtureDef); + + return fixtures; + } + + @override + Body createBody() { + // TODO(allisonryan0002): share sweeping angle with flipper when components + // are grouped. + const angle = math.pi / 7; + + final bodyDef = BodyDef() + ..position = _position + ..type = BodyType.static + ..angle = _side.isLeft ? -angle : angle; + + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + + return body; + } +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 89f60343..bcf9a92d 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,5 +1,6 @@ export 'anchor.dart'; export 'ball.dart'; +export 'baseboard.dart'; export 'board_side.dart'; export 'flipper.dart'; export 'pathway.dart'; diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index fbb68e63..c6b23dae 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -63,6 +63,7 @@ class PinballGame extends Forge2DGame ), ), ); + unawaited(_addBaseboards()); } void spawnBall() { @@ -103,6 +104,31 @@ class PinballGame extends Forge2DGame ), ); } + + Future _addBaseboards() async { + final spaceBetweenBaseboards = camera.viewport.effectiveSize.x / 2; + final baseboardY = camera.viewport.effectiveSize.y / 1.12; + + final leftBaseboard = Baseboard.left( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 - (spaceBetweenBaseboards / 2), + baseboardY, + ), + ), + ); + await add(leftBaseboard); + + final rightBaseboard = Baseboard.right( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 + (spaceBetweenBaseboards / 2), + baseboardY, + ), + ), + ); + await add(rightBaseboard); + } } class DebugPinballGame extends PinballGame with TapDetector { diff --git a/test/game/components/baseboard_test.dart b/test/game/components/baseboard_test.dart new file mode 100644 index 00000000..51002cef --- /dev/null +++ b/test/game/components/baseboard_test.dart @@ -0,0 +1,76 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('Baseboard', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + flameTester.test( + 'loads correctly', + (game) async { + await game.ready(); + final leftBaseboard = Baseboard.left(position: Vector2.zero()); + final rightBaseboard = Baseboard.right(position: Vector2.zero()); + await game.ensureAddAll([leftBaseboard, rightBaseboard]); + + expect(game.contains(leftBaseboard), isTrue); + expect(game.contains(rightBaseboard), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final baseboard = Baseboard.left(position: position); + await game.ensureAdd(baseboard); + game.contains(baseboard); + + expect(baseboard.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final baseboard = Baseboard.left(position: Vector2.zero()); + await game.ensureAdd(baseboard); + + expect(baseboard.body.bodyType, equals(BodyType.static)); + }, + ); + + flameTester.test( + 'is at an angle', + (game) async { + final leftBaseboard = Baseboard.left(position: Vector2.zero()); + final rightBaseboard = Baseboard.right(position: Vector2.zero()); + await game.ensureAddAll([leftBaseboard, rightBaseboard]); + + expect(leftBaseboard.body.angle, isNegative); + expect(rightBaseboard.body.angle, isPositive); + }, + ); + }); + + group('fixtures', () { + flameTester.test( + 'has three', + (game) async { + final baseboard = Baseboard.left(position: Vector2.zero()); + await game.ensureAdd(baseboard); + + expect(baseboard.body.fixtures.length, equals(3)); + }, + ); + }); + }); +} diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index faa55d11..a992a471 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -74,6 +74,15 @@ void main() { returnsNormally, ); }); + + flameTester.test( + 'has two Baseboards', + (game) async { + await game.ready(); + final baseboards = game.children.whereType().toList(); + expect(baseboards.length, 2); + }, + ); }); debugModeFlameTester.test('adds a ball on tap up', (game) async { From 03d48eac40316b08f4445bcb914c6ab86e6a1200 Mon Sep 17 00:00:00 2001 From: Erick Date: Fri, 11 Mar 2022 17:39:59 -0300 Subject: [PATCH 3/3] feat: adding bonus letters to board (#35) * feat: adding ball spawming upon click on debug mode * PR suggestions * fix: rebase * feat: adding bonus letter component * feat: bonus letter callback * feat: removing flip * feat: adding bonus letters to board * Apply suggestions from code review Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * feat: improving bonus letters grouping * feat: removing not needed pump * feat: rebase fix * fix: re adding wrongly removed code during rebase * fix: lint * fix: flaky test * Apply suggestions from code review Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> --- lib/game/bloc/game_state.dart | 4 + lib/game/components/bonus_word.dart | 134 +++++++++++ lib/game/components/components.dart | 1 + lib/game/pinball_game.dart | 20 ++ lib/game/view/pinball_game_page.dart | 2 + pubspec.lock | 6 +- pubspec.yaml | 8 +- test/game/bloc/game_state_test.dart | 28 +++ test/game/components/ball_test.dart | 7 +- test/game/components/bonus_word_test.dart | 244 +++++++++++++++++++++ test/game/components/plunger_test.dart | 2 +- test/game/view/pinball_game_page_test.dart | 2 +- test/helpers/builders.dart | 4 +- test/helpers/mocks.dart | 2 + 14 files changed, 450 insertions(+), 14 deletions(-) create mode 100644 lib/game/components/bonus_word.dart create mode 100644 test/game/components/bonus_word_test.dart diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 2812a049..e2c39d1f 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -49,6 +49,10 @@ class GameState extends Equatable { /// Determines when the player has only one ball left. bool get isLastBall => balls == 1; + /// Shortcut method to check if the given [i] + /// is activated. + bool isLetterActivated(int i) => activatedBonusLetters.contains(i); + GameState copyWith({ int? score, int? balls, diff --git a/lib/game/components/bonus_word.dart b/lib/game/components/bonus_word.dart new file mode 100644 index 00000000..35412ecf --- /dev/null +++ b/lib/game/components/bonus_word.dart @@ -0,0 +1,134 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template bonus_word} +/// Loads all [BonusLetter]s to compose a [BonusWord]. +/// {@endtemplate} +class BonusWord extends Component { + /// {@macro bonus_word} + BonusWord({required Vector2 position}) : _position = position; + + final Vector2 _position; + + @override + Future onLoad() async { + await super.onLoad(); + final letters = GameBloc.bonusWord.split(''); + + for (var i = 0; i < letters.length; i++) { + unawaited( + add( + BonusLetter( + position: _position - Vector2(16 - (i * 6), -30), + letter: letters[i], + index: i, + ), + ), + ); + } + } +} + +/// {@template bonus_letter} +/// [BodyType.static] sensor component, part of a word bonus, +/// which will activate its letter after contact with a [Ball]. +/// {@endtemplate} +class BonusLetter extends BodyComponent + with BlocComponent { + /// {@macro bonus_letter} + BonusLetter({ + required Vector2 position, + required String letter, + required int index, + }) : _position = position, + _letter = letter, + _index = index { + paint = Paint()..color = _disableColor; + } + + /// The area size of this [BonusLetter]. + static final areaSize = Vector2.all(4); + + static const _activeColor = Colors.green; + static const _disableColor = Colors.red; + + final Vector2 _position; + final String _letter; + final int _index; + + @override + Future onLoad() async { + await super.onLoad(); + + await add( + TextComponent( + position: Vector2(-1, -1), + text: _letter, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 2, color: Colors.white), + ), + ), + ); + } + + @override + Body createBody() { + final shape = CircleShape()..radius = areaSize.x / 2; + + final fixtureDef = FixtureDef(shape)..isSensor = true; + + final bodyDef = BodyDef() + ..userData = this + ..position = _position + ..type = BodyType.static; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } + + @override + bool listenWhen(GameState? previousState, GameState newState) { + final wasActive = previousState?.isLetterActivated(_index) ?? false; + final isActive = newState.isLetterActivated(_index); + + return wasActive != isActive; + } + + @override + void onNewState(GameState state) { + final isActive = state.isLetterActivated(_index); + + add( + ColorEffect( + isActive ? _activeColor : _disableColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ); + } + + /// Activates this [BonusLetter], if it's not already activated. + void activate() { + final isActive = state?.isLetterActivated(_index) ?? false; + if (!isActive) { + gameRef.read().add(BonusLetterActivated(_index)); + } + } +} + +/// Triggers [BonusLetter.activate] method when a [BonusLetter] and a [Ball] +/// come in contact. +class BonusLetterBallContactCallback + extends ContactCallback { + @override + void begin(Ball ball, BonusLetter bonusLetter, Contact contact) { + bonusLetter.activate(); + } +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index bcf9a92d..6e663c26 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -2,6 +2,7 @@ export 'anchor.dart'; export 'ball.dart'; export 'baseboard.dart'; export 'board_side.dart'; +export 'bonus_word.dart'; export 'flipper.dart'; export 'pathway.dart'; export 'plunger.dart'; diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index c6b23dae..633c2351 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -48,6 +48,25 @@ class PinballGame extends Forge2DGame ), ); + unawaited(_addFlippers()); + + unawaited(_addBonusWord()); + } + + Future _addBonusWord() async { + await add( + BonusWord( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2, + camera.viewport.effectiveSize.y - 50, + ), + ), + ), + ); + } + + Future _addFlippers() async { final flippersPosition = screenToWorld( Vector2( camera.viewport.effectiveSize.x / 2, @@ -73,6 +92,7 @@ class PinballGame extends Forge2DGame void _addContactCallbacks() { addContactCallback(BallScorePointsCallback()); addContactCallback(BottomWallBallContactCallback()); + addContactCallback(BonusLetterBallContactCallback()); } Future _addGameBoundaries() async { diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 4af2168e..21bd4074 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -63,6 +63,8 @@ class _PinballGameViewState extends State { @override Widget build(BuildContext context) { return BlocListener( + listenWhen: (previous, current) => + previous.isGameOver != current.isGameOver, listener: (context, state) { if (state.isGameOver) { showDialog( diff --git a/pubspec.lock b/pubspec.lock index 4b375ff7..8ff31de7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -140,21 +140,21 @@ packages: name: flame url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-releasecandidate.2" + version: "1.1.0-releasecandidate.4" flame_bloc: dependency: "direct main" description: name: flame_bloc url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-releasecandidate.2" + version: "1.2.0-releasecandidate.4" flame_forge2d: dependency: "direct main" description: name: flame_forge2d url: "https://pub.dartlang.org" source: hosted - version: "0.9.0-releasecandidate.2" + version: "0.9.0-releasecandidate.4" flame_test: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 41b0d081..39b93fe7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,15 +9,15 @@ environment: dependencies: bloc: ^8.0.2 equatable: ^2.0.3 - flame: ^1.1.0-releasecandidate.2 - flame_bloc: ^1.2.0-releasecandidate.2 - flame_forge2d: ^0.9.0-releasecandidate.2 + flame: ^1.1.0-releasecandidate.4 + flame_bloc: ^1.2.0-releasecandidate.4 + flame_forge2d: ^0.9.0-releasecandidate.4 flutter: sdk: flutter flutter_bloc: ^8.0.1 flutter_localizations: sdk: flutter - geometry: + geometry: path: packages/geometry intl: ^0.17.0 pinball_theme: diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index 7b060984..8ab72e6c 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -126,6 +126,34 @@ void main() { ); }); + group('isLetterActivated', () { + test( + 'is true when the letter is activated', + () { + const gameState = GameState( + balls: 3, + score: 0, + activatedBonusLetters: [1], + bonusHistory: [], + ); + expect(gameState.isLetterActivated(1), isTrue); + }, + ); + + test( + 'is false when the letter is not activated', + () { + const gameState = GameState( + balls: 3, + score: 0, + activatedBonusLetters: [1], + bonusHistory: [], + ); + expect(gameState.isLetterActivated(0), isFalse); + }, + ); + }); + group('copyWith', () { test( 'throws AssertionError ' diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index 9f473e42..8ac32212 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -89,9 +89,10 @@ void main() { }); group('resetting a ball', () { - final gameBloc = MockGameBloc(); + late GameBloc gameBloc; setUp(() { + gameBloc = MockGameBloc(); whenListen( gameBloc, const Stream.empty(), @@ -99,7 +100,7 @@ void main() { ); }); - final tester = flameBlocTester(gameBloc: gameBloc); + final tester = flameBlocTester(gameBloc: () => gameBloc); tester.widgetTest( 'adds BallLost to GameBloc', @@ -118,7 +119,7 @@ void main() { (game, tester) async { await game.ready(); - game.children.whereType().first.removeFromParent(); + game.children.whereType().first.lost(); await game.ready(); // Making sure that all additions are done expect( diff --git a/test/game/components/bonus_word_test.dart b/test/game/components/bonus_word_test.dart new file mode 100644 index 00000000..129f68d1 --- /dev/null +++ b/test/game/components/bonus_word_test.dart @@ -0,0 +1,244 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('BonusWord', () { + final flameTester = FlameTester(PinballGameTest.create); + + flameTester.test( + 'loads the letters correctly', + (game) async { + await game.ready(); + + final bonusWord = game.children.whereType().first; + final letters = bonusWord.children.whereType(); + expect(letters.length, equals(GameBloc.bonusWord.length)); + }, + ); + }); + + group('BonusLetter', () { + final flameTester = FlameTester(PinballGameTest.create); + + flameTester.test( + 'loads correctly', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + await game.ready(); + + expect(game.contains(bonusLetter), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final bonusLetter = BonusLetter( + position: position, + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + game.contains(bonusLetter); + + expect(bonusLetter.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + expect(bonusLetter.body.bodyType, equals(BodyType.static)); + }, + ); + }); + + group('fixture', () { + flameTester.test( + 'exists', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + expect(bonusLetter.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'is sensor', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + final fixture = bonusLetter.body.fixtures[0]; + expect(fixture.isSensor, isTrue); + }, + ); + + flameTester.test( + 'shape is circular', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + final fixture = bonusLetter.body.fixtures[0]; + expect(fixture.shape.shapeType, equals(ShapeType.circle)); + expect(fixture.shape.radius, equals(2)); + }, + ); + }); + + group('bonus letter activation', () { + final gameBloc = MockGameBloc(); + + BonusLetter _getBonusLetter(PinballGame game) { + return game.children + .whereType() + .first + .children + .whereType() + .first; + } + + setUp(() { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final tester = flameBlocTester(gameBloc: () => gameBloc); + + tester.widgetTest( + 'adds BonusLetterActivated to GameBloc when not activated', + (game, tester) async { + await game.ready(); + + _getBonusLetter(game).activate(); + + await tester.pump(); + + verify(() => gameBloc.add(const BonusLetterActivated(0))).called(1); + }, + ); + + tester.widgetTest( + "doesn't add BonusLetterActivated to GameBloc when already activated", + (game, tester) async { + const state = GameState( + score: 0, + balls: 2, + activatedBonusLetters: [0], + bonusHistory: [], + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + await game.ready(); + + _getBonusLetter(game).activate(); + await game.ready(); // Making sure that all additions are done + + verifyNever(() => gameBloc.add(const BonusLetterActivated(0))); + }, + ); + + tester.widgetTest( + 'adds a ColorEffect', + (game, tester) async { + await game.ready(); + + const state = GameState( + score: 0, + balls: 2, + activatedBonusLetters: [0], + bonusHistory: [], + ); + + final bonusLetter = _getBonusLetter(game); + + bonusLetter.onNewState(state); + await tester.pump(); + + expect( + bonusLetter.children.whereType().length, + equals(1), + ); + }, + ); + + tester.widgetTest( + 'only listens when there is a change on the letter status', + (game, tester) async { + await game.ready(); + + const state = GameState( + score: 0, + balls: 2, + activatedBonusLetters: [0], + bonusHistory: [], + ); + + final bonusLetter = _getBonusLetter(game); + + expect( + bonusLetter.listenWhen(const GameState.initial(), state), + isTrue, + ); + }, + ); + }); + + group('BonusLetterBallContactCallback', () { + test('calls ball.activate', () { + final ball = MockBall(); + final bonusLetter = MockBonusLetter(); + + final contactCallback = BonusLetterBallContactCallback(); + contactCallback.begin(ball, bonusLetter, MockContact()); + + verify(bonusLetter.activate).called(1); + }); + }); + }); +} diff --git a/test/game/components/plunger_test.dart b/test/game/components/plunger_test.dart index 02330b31..1ca29e85 100644 --- a/test/game/components/plunger_test.dart +++ b/test/game/components/plunger_test.dart @@ -227,7 +227,7 @@ void main() { ); }); - final flameTester = flameBlocTester(gameBloc: gameBloc); + final flameTester = flameBlocTester(gameBloc: () => gameBloc); group('initializes with', () { flameTester.test( diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 9de36cde..dcf0c001 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -94,7 +94,7 @@ void main() { whenListen( gameBloc, Stream.value(state), - initialState: state, + initialState: GameState.initial(), ); await tester.pumpApp( diff --git a/test/helpers/builders.dart b/test/helpers/builders.dart index c77e55c5..d8ffd715 100644 --- a/test/helpers/builders.dart +++ b/test/helpers/builders.dart @@ -5,14 +5,14 @@ import 'package:pinball/game/game.dart'; import 'helpers.dart'; FlameTester flameBlocTester({ - required GameBloc gameBloc, + required GameBloc Function() gameBloc, }) { return FlameTester( PinballGameTest.create, pumpWidget: (gameWidget, tester) async { await tester.pumpWidget( BlocProvider.value( - value: gameBloc, + value: gameBloc(), child: gameWidget, ), ); diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 46886752..c1c59377 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -37,3 +37,5 @@ class MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { class MockTapUpInfo extends Mock implements TapUpInfo {} class MockEventPosition extends Mock implements EventPosition {} + +class MockBonusLetter extends Mock implements BonusLetter {}