diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 42c79ae6..95134ec2 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,4 +1,5 @@ export 'anchor.dart'; export 'ball.dart'; +export 'plunger.dart'; export 'score_points.dart'; export 'wall.dart'; diff --git a/lib/game/components/plunger.dart b/lib/game/components/plunger.dart new file mode 100644 index 00000000..ed1ef36f --- /dev/null +++ b/lib/game/components/plunger.dart @@ -0,0 +1,72 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template plunger} +/// [Plunger] serves as a spring, that shoots the ball on the right side of the +/// playfield. +/// +/// [Plunger] ignores gravity so the player controls its downward [pull]. +/// {@endtemplate} +class Plunger extends BodyComponent { + /// {@macro plunger} + Plunger({required Vector2 position}) : _position = position; + + final Vector2 _position; + + @override + Body createBody() { + final shape = PolygonShape()..setAsBoxXY(2.5, 1.5); + + final fixtureDef = FixtureDef(shape); + + final bodyDef = BodyDef() + ..userData = this + ..position = _position + ..type = BodyType.dynamic + ..gravityScale = 0; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } + + /// Set a constant downward velocity on the [Plunger]. + void pull() { + body.linearVelocity = Vector2(0, -7); + } + + /// Set an upward velocity on the [Plunger]. + /// + /// The velocity's magnitude depends on how far the [Plunger] has been pulled + /// from its original [_position]. + void release() { + final velocity = (_position.y - body.position.y) * 9; + body.linearVelocity = Vector2(0, velocity); + } +} + +/// {@template plunger_anchor_prismatic_joint_def} +/// [PrismaticJointDef] between a [Plunger] and an [Anchor] with motion on +/// the vertical axis. +/// +/// The [Plunger] is constrained vertically between its starting position and +/// the [Anchor]. The [Anchor] must be below the [Plunger]. +/// {@endtemplate} +class PlungerAnchorPrismaticJointDef extends PrismaticJointDef { + /// {@macro plunger_anchor_prismatic_joint_def} + PlungerAnchorPrismaticJointDef({ + required Plunger plunger, + required Anchor anchor, + }) : assert( + anchor.body.position.y < plunger.body.position.y, + 'Anchor must be below the Plunger', + ) { + initialize( + plunger.body, + anchor.body, + anchor.body.position, + Vector2(0, -1), + ); + enableLimit = true; + lowerTranslation = double.negativeInfinity; + collideConnected = true; + } +} diff --git a/test/game/components/plunger_test.dart b/test/game/components/plunger_test.dart new file mode 100644 index 00000000..67e215fd --- /dev/null +++ b/test/game/components/plunger_test.dart @@ -0,0 +1,302 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGame.new); + + group('Plunger', () { + flameTester.test( + 'loads correctly', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(game.contains(plunger), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final plunger = Plunger(position: position); + await game.ensureAdd(plunger); + game.contains(plunger); + + expect(plunger.body.position, position); + }, + ); + + flameTester.test( + 'is dynamic', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(plunger.body.bodyType, equals(BodyType.dynamic)); + }, + ); + + flameTester.test( + 'ignores gravity', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(plunger.body.gravityScale, isZero); + }, + ); + }); + + group('first fixture', () { + flameTester.test( + 'exists', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(plunger.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'shape is a polygon', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + final fixture = plunger.body.fixtures[0]; + expect(fixture.shape.shapeType, equals(ShapeType.polygon)); + }, + ); + }); + + flameTester.test( + 'pull sets a negative linear velocity', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + plunger.pull(); + + expect(plunger.body.linearVelocity.y, isNegative); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + + group('release', () { + flameTester.test( + 'does not set a linear velocity ' + 'when plunger is in starting position', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + plunger.release(); + + expect(plunger.body.linearVelocity.y, isZero); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + + flameTester.test( + 'sets a positive linear velocity ' + 'when plunger is below starting position', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + plunger.body.setTransform(Vector2(0, -1), 0); + plunger.release(); + + expect(plunger.body.linearVelocity.y, isPositive); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + }); + }); + + group('PlungerAnchorPrismaticJointDef', () { + late GameBloc gameBloc; + late Plunger plunger; + late Anchor anchor; + + setUp(() { + gameBloc = MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + plunger = Plunger(position: Vector2.zero()); + anchor = Anchor(position: Vector2(0, -1)); + }); + + final flameTester = flameBlocTester( + gameBlocBuilder: () { + return gameBloc; + }, + ); + + flameTester.test( + 'throws AssertionError ' + 'when anchor is above plunger', + (game) async { + final anchor = Anchor(position: Vector2(0, 1)); + await game.ensureAddAll([plunger, anchor]); + + expect( + () => PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ), + throwsAssertionError, + ); + }, + ); + + flameTester.test( + 'throws AssertionError ' + 'when anchor is in same position as plunger', + (game) async { + final anchor = Anchor(position: Vector2.zero()); + await game.ensureAddAll([plunger, anchor]); + + expect( + () => PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ), + throwsAssertionError, + ); + }, + ); + + group('initializes with', () { + flameTester.test( + 'plunger body as bodyA', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + + expect(jointDef.bodyA, equals(plunger.body)); + }, + ); + + flameTester.test( + 'anchor body as bodyB', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.bodyB, equals(anchor.body)); + }, + ); + + flameTester.test( + 'limits enabled', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.enableLimit, isTrue); + }, + ); + + flameTester.test( + 'lower translation limit as negative infinity', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.lowerTranslation, equals(double.negativeInfinity)); + }, + ); + + flameTester.test( + 'connected body collison enabled', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.collideConnected, isTrue); + }, + ); + }); + + flameTester.widgetTest( + 'plunger cannot go below anchor', + (game, tester) async { + await game.ensureAddAll([plunger, anchor]); + + // Giving anchor a shape for the plunger to collide with. + anchor.body.createFixtureFromShape(PolygonShape()..setAsBoxXY(2, 1)); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + plunger.pull(); + await tester.pump(const Duration(seconds: 1)); + + expect(plunger.body.position.y > anchor.body.position.y, isTrue); + }, + ); + + flameTester.widgetTest( + 'plunger cannot excessively exceed starting position', + (game, tester) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + plunger.pull(); + await tester.pump(const Duration(seconds: 1)); + + plunger.release(); + await tester.pump(const Duration(seconds: 1)); + + expect(plunger.body.position.y < 1, isTrue); + }, + ); + }); +} diff --git a/test/helpers/builders.dart b/test/helpers/builders.dart index 5ef98226..e124052e 100644 --- a/test/helpers/builders.dart +++ b/test/helpers/builders.dart @@ -2,8 +2,10 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; -FlameTester flameBlocTester({required GameBloc Function() gameBlocBuilder}) { - return FlameTester( +FlameTester flameBlocTester({ + required GameBloc Function() gameBlocBuilder, +}) { + return FlameTester( PinballGame.new, pumpWidget: (gameWidget, tester) async { await tester.pumpWidget(