diff --git a/firebase.json b/firebase.json index 0d25a779..80e2ae69 100644 --- a/firebase.json +++ b/firebase.json @@ -1,6 +1,7 @@ { "hosting": { "public": "build/web", + "site": "ashehwkdkdjruejdnensjsjdne", "ignore": [ "firebase.json", "**/.*", diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index 71c527a8..3b5c16b0 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -9,6 +9,7 @@ class GameBloc extends Bloc { GameBloc() : super(const GameState.initial()) { on(_onBallLost); on(_onScored); + on(_onBonusLetterActivated); } void _onBallLost(BallLost event, Emitter emit) { @@ -22,4 +23,15 @@ class GameBloc extends Bloc { emit(state.copyWith(score: state.score + event.points)); } } + + void _onBonusLetterActivated(BonusLetterActivated event, Emitter emit) { + emit( + state.copyWith( + bonusLetters: [ + ...state.bonusLetters, + event.letter, + ], + ), + ); + } } diff --git a/lib/game/bloc/game_event.dart b/lib/game/bloc/game_event.dart index 88ef265b..417f6322 100644 --- a/lib/game/bloc/game_event.dart +++ b/lib/game/bloc/game_event.dart @@ -24,3 +24,12 @@ class Scored extends GameEvent { @override List get props => [points]; } + +class BonusLetterActivated extends GameEvent { + const BonusLetterActivated(this.letter); + + final String letter; + + @override + List get props => [letter]; +} diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 1a0568f7..8a5ab298 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -8,12 +8,14 @@ class GameState extends Equatable { const GameState({ required this.score, required this.balls, + required this.bonusLetters, }) : assert(score >= 0, "Score can't be negative"), assert(balls >= 0, "Number of balls can't be negative"); const GameState.initial() : score = 0, - balls = 3; + balls = 3, + bonusLetters = const []; /// The current score of the game. final int score; @@ -23,6 +25,9 @@ class GameState extends Equatable { /// When the number of balls is 0, the game is over. final int balls; + /// Active bonus letters. + final List bonusLetters; + /// Determines when the game is over. bool get isGameOver => balls == 0; @@ -32,6 +37,7 @@ class GameState extends Equatable { GameState copyWith({ int? score, int? balls, + List? bonusLetters, }) { assert( score == null || score >= this.score, @@ -41,6 +47,7 @@ class GameState extends Equatable { return GameState( score: score ?? this.score, balls: balls ?? this.balls, + bonusLetters: bonusLetters ?? this.bonusLetters, ); } @@ -48,5 +55,6 @@ class GameState extends Equatable { List get props => [ score, balls, + bonusLetters, ]; } diff --git a/lib/game/components/board_side.dart b/lib/game/components/board_side.dart new file mode 100644 index 00000000..611f70b8 --- /dev/null +++ b/lib/game/components/board_side.dart @@ -0,0 +1,22 @@ +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]. +enum BoardSide { + /// The left side of the board. + left, + + /// The right side of the board. + right, +} + +/// Utility methods for [BoardSide]. +extension BoardSideX on BoardSide { + /// Whether this side is [BoardSide.left]. + bool get isLeft => this == BoardSide.left; + + /// Whether this side is [BoardSide.right]. + bool get isRight => this == BoardSide.right; +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 95134ec2..89f60343 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,5 +1,8 @@ export 'anchor.dart'; export 'ball.dart'; +export 'board_side.dart'; +export 'flipper.dart'; +export 'pathway.dart'; export 'plunger.dart'; export 'score_points.dart'; export 'wall.dart'; diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart new file mode 100644 index 00000000..bd071b93 --- /dev/null +++ b/lib/game/components/flipper.dart @@ -0,0 +1,241 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flame/input.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template flipper} +/// A bat, typically found in pairs at the bottom of the board. +/// +/// [Flipper] can be controlled by the player in an arc motion. +/// {@endtemplate flipper} +class Flipper extends BodyComponent with KeyboardHandler { + /// {@macro flipper} + Flipper._({ + required Vector2 position, + required this.side, + required List keys, + }) : _position = position, + _keys = keys { + // TODO(alestiago): Use sprite instead of color when provided. + paint = Paint() + ..color = const Color(0xFF00FF00) + ..style = PaintingStyle.fill; + } + + /// A left positioned [Flipper]. + Flipper.left({ + required Vector2 position, + }) : this._( + position: position, + side: BoardSide.left, + keys: [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.keyA, + ], + ); + + /// A right positioned [Flipper]. + Flipper.right({ + required Vector2 position, + }) : this._( + position: position, + side: BoardSide.right, + keys: [ + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.keyD, + ], + ); + + /// The width of the [Flipper]. + static const width = 12.0; + + /// The height of the [Flipper]. + static const height = 2.8; + + /// The speed required to move the [Flipper] to its highest position. + /// + /// The higher the value, the faster the [Flipper] will move. + static const double _speed = 60; + + /// Whether the [Flipper] is on the left or right side of the board. + /// + /// A [Flipper] with [BoardSide.left] has a counter-clockwise arc motion, + /// whereas a [Flipper] with [BoardSide.right] has a clockwise arc motion. + final BoardSide side; + + /// The initial position of the [Flipper] body. + final Vector2 _position; + + /// The [LogicalKeyboardKey]s that will control the [Flipper]. + /// + /// [onKeyEvent] method listens to when one of these keys is pressed. + final List _keys; + + /// Applies downward linear velocity to the [Flipper], moving it to its + /// resting position. + void _moveDown() { + body.linearVelocity = Vector2(0, -_speed); + } + + /// Applies upward linear velocity to the [Flipper], moving it to its highest + /// position. + void _moveUp() { + body.linearVelocity = Vector2(0, _speed); + } + + List _createFixtureDefs() { + final fixtures = []; + final isLeft = side.isLeft; + + final bigCircleShape = CircleShape()..radius = height / 2; + bigCircleShape.position.setValues( + isLeft + ? -(width / 2) + bigCircleShape.radius + : (width / 2) - bigCircleShape.radius, + 0, + ); + final bigCircleFixtureDef = FixtureDef(bigCircleShape); + fixtures.add(bigCircleFixtureDef); + + final smallCircleShape = CircleShape()..radius = bigCircleShape.radius / 2; + smallCircleShape.position.setValues( + isLeft + ? (width / 2) - smallCircleShape.radius + : -(width / 2) + smallCircleShape.radius, + 0, + ); + final smallCircleFixtureDef = FixtureDef(smallCircleShape); + fixtures.add(smallCircleFixtureDef); + + final trapeziumVertices = isLeft + ? [ + Vector2(bigCircleShape.position.x, bigCircleShape.radius), + Vector2(smallCircleShape.position.x, smallCircleShape.radius), + Vector2(smallCircleShape.position.x, -smallCircleShape.radius), + Vector2(bigCircleShape.position.x, -bigCircleShape.radius), + ] + : [ + Vector2(smallCircleShape.position.x, smallCircleShape.radius), + Vector2(bigCircleShape.position.x, bigCircleShape.radius), + Vector2(bigCircleShape.position.x, -bigCircleShape.radius), + Vector2(smallCircleShape.position.x, -smallCircleShape.radius), + ]; + final trapezium = PolygonShape()..set(trapeziumVertices); + final trapeziumFixtureDef = FixtureDef(trapezium) + ..density = 50.0 // TODO(alestiago): Use a proper density. + ..friction = .1; // TODO(alestiago): Use a proper friction. + fixtures.add(trapeziumFixtureDef); + + return fixtures; + } + + @override + Body createBody() { + final bodyDef = BodyDef() + ..gravityScale = 0 + ..type = BodyType.dynamic + ..position = _position; + + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + + return body; + } + + // TODO(erickzanardo): Remove this once the issue is solved: + // https://github.com/flame-engine/flame/issues/1417 + final Completer hasMounted = Completer(); + + @override + void onMount() { + super.onMount(); + hasMounted.complete(); + } + + @override + bool onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + // TODO(alestiago): Check why false cancels the event for other components. + // Investigate why return is of type [bool] expected instead of a type + // [KeyEventResult]. + if (!_keys.contains(event.logicalKey)) return true; + + if (event is RawKeyDownEvent) { + _moveUp(); + } else if (event is RawKeyUpEvent) { + _moveDown(); + } + + return true; + } +} + +/// {@template flipper_anchor} +/// [Anchor] positioned at the end of a [Flipper]. +/// +/// The end of a [Flipper] depends on its [Flipper.side]. +/// {@endtemplate} +class FlipperAnchor extends Anchor { + /// {@macro flipper_anchor} + FlipperAnchor({ + required Flipper flipper, + }) : super( + position: Vector2( + flipper.side.isLeft + ? flipper.body.position.x - Flipper.width / 2 + : flipper.body.position.x + Flipper.width / 2, + flipper.body.position.y, + ), + ); +} + +/// {@template flipper_anchor_revolute_joint_def} +/// Hinges one end of [Flipper] to a [Anchor] to achieve an arc motion. +/// {@endtemplate} +class FlipperAnchorRevoluteJointDef extends RevoluteJointDef { + /// {@macro flipper_anchor_revolute_joint_def} + FlipperAnchorRevoluteJointDef({ + required Flipper flipper, + required Anchor anchor, + }) { + initialize( + flipper.body, + anchor.body, + anchor.body.position, + ); + enableLimit = true; + + final angle = (flipper.side.isLeft ? _sweepingAngle : -_sweepingAngle) / 2; + lowerAngle = upperAngle = angle; + } + + /// The total angle of the arc motion. + static const _sweepingAngle = math.pi / 3.5; + + /// Unlocks the [Flipper] from its resting position. + /// + /// The [Flipper] is locked when initialized in order to force it to be at + /// its resting position. + // TODO(alestiago): consider refactor once the issue is solved: + // https://github.com/flame-engine/forge2d/issues/36 + static void unlock(RevoluteJoint joint, BoardSide side) { + late final double upperLimit, lowerLimit; + switch (side) { + case BoardSide.left: + lowerLimit = -joint.lowerLimit; + upperLimit = joint.upperLimit; + break; + case BoardSide.right: + lowerLimit = joint.lowerLimit; + upperLimit = -joint.upperLimit; + } + + joint.setLimits(lowerLimit, upperLimit); + } +} diff --git a/lib/game/components/pathway.dart b/lib/game/components/pathway.dart new file mode 100644 index 00000000..d41ce3da --- /dev/null +++ b/lib/game/components/pathway.dart @@ -0,0 +1,178 @@ +import 'package:flame/extensions.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:geometry/geometry.dart'; + +/// {@template pathway} +/// [Pathway] creates lines of various shapes. +/// +/// [BodyComponent]s such as a Ball can collide and move along a [Pathway]. +/// {@endtemplate} +class Pathway extends BodyComponent { + Pathway._({ + // TODO(ruialonso): remove color when assets added. + Color? color, + required Vector2 position, + required List> paths, + }) : _position = position, + _paths = paths { + paint = Paint() + ..color = color ?? const Color.fromARGB(0, 0, 0, 0) + ..style = PaintingStyle.stroke; + } + + /// Creates a uniform unidirectional (straight) [Pathway]. + /// + /// Does so with two [ChainShape] separated by a [width]. Placed + /// at a [position] between [start] and [end] points. Can + /// be rotated by a given [rotation] in radians. + /// + /// If [singleWall] is true, just one [ChainShape] is created. + factory Pathway.straight({ + Color? color, + required Vector2 position, + required Vector2 start, + required Vector2 end, + required double width, + double rotation = 0, + bool singleWall = false, + }) { + final paths = >[]; + + // TODO(ruialonso): Refactor repetitive logic + final firstWall = [ + start.clone(), + end.clone(), + ].map((vector) => vector..rotate(rotation)).toList(); + paths.add(firstWall); + + if (!singleWall) { + final secondWall = [ + start + Vector2(width, 0), + end + Vector2(width, 0), + ].map((vector) => vector..rotate(rotation)).toList(); + paths.add(secondWall); + } + + return Pathway._( + color: color, + position: position, + paths: paths, + ); + } + + /// Creates an arc [Pathway]. + /// + /// The [angle], in radians, specifies the size of the arc. For example, 2*pi + /// returns a complete circumference and minor angles a semi circumference. + /// + /// The center of the arc is placed at [position]. + /// + /// Does so with two [ChainShape] separated by a [width]. Which can be + /// rotated by a given [rotation] in radians. + /// + /// The outer radius is specified by [radius], whilst the inner one is + /// equivalent to [radius] - [width]. + /// + /// If [singleWall] is true, just one [ChainShape] is created. + factory Pathway.arc({ + Color? color, + required Vector2 position, + required double width, + required double radius, + required double angle, + double rotation = 0, + bool singleWall = false, + }) { + final paths = >[]; + + // TODO(ruialonso): Refactor repetitive logic + final outerWall = calculateArc( + center: position, + radius: radius, + angle: angle, + offsetAngle: rotation, + ); + paths.add(outerWall); + + if (!singleWall) { + final innerWall = calculateArc( + center: position, + radius: radius - width, + angle: angle, + offsetAngle: rotation, + ); + paths.add(innerWall); + } + + return Pathway._( + color: color, + position: position, + paths: paths, + ); + } + + /// Creates a bezier curve [Pathway]. + /// + /// Does so with two [ChainShape] separated by a [width]. Which can be + /// rotated by a given [rotation] in radians. + /// + /// First and last [controlPoints] set the beginning and end of the curve, + /// inner points between them set its final shape. + /// + /// If [singleWall] is true, just one [ChainShape] is created. + factory Pathway.bezierCurve({ + Color? color, + required Vector2 position, + required List controlPoints, + required double width, + double rotation = 0, + bool singleWall = false, + }) { + final paths = >[]; + + // TODO(ruialonso): Refactor repetitive logic + final firstWall = calculateBezierCurve(controlPoints: controlPoints) + .map((vector) => vector..rotate(rotation)) + .toList(); + paths.add(firstWall); + + if (!singleWall) { + final secondWall = calculateBezierCurve( + controlPoints: controlPoints + .map((vector) => vector + Vector2(width, -width)) + .toList(), + ).map((vector) => vector..rotate(rotation)).toList(); + paths.add(secondWall); + } + + return Pathway._( + color: color, + position: position, + paths: paths, + ); + } + + final Vector2 _position; + final List> _paths; + + @override + Body createBody() { + final bodyDef = BodyDef() + ..type = BodyType.static + ..position = _position; + + final body = world.createBody(bodyDef); + for (final path in _paths) { + final chain = ChainShape() + ..createChain( + path.map(gameRef.screenToWorld).toList(), + ); + final fixtureDef = FixtureDef(chain); + + body.createFixture(fixtureDef); + } + + return body; + } +} diff --git a/lib/game/components/plunger.dart b/lib/game/components/plunger.dart index ed1ef36f..364fc35e 100644 --- a/lib/game/components/plunger.dart +++ b/lib/game/components/plunger.dart @@ -1,5 +1,5 @@ import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball/game/game.dart'; +import 'package:pinball/game/game.dart' show Anchor; /// {@template plunger} /// [Plunger] serves as a spring, that shoots the ball on the right side of the diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index aa6dd66d..7231f315 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -1,21 +1,16 @@ 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/game/game.dart'; import 'package:pinball_theme/pinball_theme.dart'; -class PinballGame extends Forge2DGame with FlameBloc { +class PinballGame extends Forge2DGame + with FlameBloc, HasKeyboardHandlerComponents { PinballGame({required this.theme}); final PinballTheme theme; - void spawnBall() { - add( - Ball(position: ballStartingPosition), - ); - } - // TODO(erickzanardo): Change to the plumber position late final ballStartingPosition = screenToWorld( Vector2( @@ -25,17 +20,86 @@ class PinballGame extends Forge2DGame with FlameBloc { ) - Vector2(0, -20); + // TODO(alestiago): Change to the design position. + late final flippersPosition = ballStartingPosition - Vector2(0, 5); + + @override + void onAttach() { + super.onAttach(); + spawnBall(); + } + + void spawnBall() { + add(Ball(position: ballStartingPosition)); + } + @override Future onLoad() async { addContactCallback(BallScorePointsCallback()); await add(BottomWall(this)); addContactCallback(BottomWallBallContactCallback()); + + unawaited(_addFlippers()); } - @override - void onAttach() { - super.onAttach(); - spawnBall(); + Future _addFlippers() async { + 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, + ), + ), + ); } } diff --git a/lib/game/view/game_hud.dart b/lib/game/view/game_hud.dart new file mode 100644 index 00000000..00eedd2b --- /dev/null +++ b/lib/game/view/game_hud.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template game_hud} +/// Overlay of a [PinballGame] that displays the current [GameState.score] and +/// [GameState.balls]. +/// {@endtemplate} +class GameHud extends StatelessWidget { + /// {@macro game_hud} + const GameHud({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + + return Container( + color: Colors.redAccent, + width: 200, + height: 100, + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${state.score}', + style: Theme.of(context).textTheme.headline3, + ), + Wrap( + direction: Axis.vertical, + children: [ + for (var i = 0; i < state.balls; i++) + const Padding( + padding: EdgeInsets.only(top: 6, right: 6), + child: CircleAvatar( + radius: 8, + backgroundColor: Colors.black, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index e954b591..3801fcb1 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -61,7 +61,18 @@ class _PinballGameViewState extends State { ); } }, - child: GameWidget(game: _game), + child: Stack( + children: [ + Positioned.fill( + child: GameWidget(game: _game), + ), + const Positioned( + top: 8, + left: 8, + child: GameHud(), + ), + ], + ), ); } } diff --git a/lib/game/view/view.dart b/lib/game/view/view.dart index 53d3813a..26b700d3 100644 --- a/lib/game/view/view.dart +++ b/lib/game/view/view.dart @@ -1,2 +1,3 @@ +export 'game_hud.dart'; export 'pinball_game_page.dart'; export 'widgets/widgets.dart'; diff --git a/packages/geometry/.gitignore b/packages/geometry/.gitignore new file mode 100644 index 00000000..526da158 --- /dev/null +++ b/packages/geometry/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/geometry/README.md b/packages/geometry/README.md new file mode 100644 index 00000000..f0841d82 --- /dev/null +++ b/packages/geometry/README.md @@ -0,0 +1,11 @@ +# geometry + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Helper package to calculate points of lines, arcs and curves for the pathways of the ball. + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis \ No newline at end of file diff --git a/packages/geometry/analysis_options.yaml b/packages/geometry/analysis_options.yaml new file mode 100644 index 00000000..3742fc3d --- /dev/null +++ b/packages/geometry/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.2.4.0.yaml \ No newline at end of file diff --git a/packages/geometry/lib/geometry.dart b/packages/geometry/lib/geometry.dart new file mode 100644 index 00000000..2453ed05 --- /dev/null +++ b/packages/geometry/lib/geometry.dart @@ -0,0 +1,3 @@ +library geometry; + +export 'src/geometry.dart'; diff --git a/packages/geometry/lib/src/geometry.dart b/packages/geometry/lib/src/geometry.dart new file mode 100644 index 00000000..dceb4e9e --- /dev/null +++ b/packages/geometry/lib/src/geometry.dart @@ -0,0 +1,107 @@ +import 'dart:math' as math; +import 'package:flame/extensions.dart'; + +/// Calculates all [Vector2]s of a circumference. +/// +/// A circumference can be achieved by specifying a [center] and a [radius]. +/// In addition, a semi-circle can be achieved by specifying its [angle] and an +/// [offsetAngle] (both in radians). +/// +/// The higher the [precision], the more [Vector2]s will be calculated; +/// achieving a more rounded arc. +/// +/// For more information read: https://en.wikipedia.org/wiki/Trigonometric_functions. +List calculateArc({ + required Vector2 center, + required double radius, + required double angle, + double offsetAngle = 0, + int precision = 100, +}) { + final stepAngle = angle / (precision - 1); + + final points = []; + for (var i = 0; i < precision; i++) { + final xCoord = center.x + radius * math.cos((stepAngle * i) + offsetAngle); + final yCoord = center.y - radius * math.sin((stepAngle * i) + offsetAngle); + + final point = Vector2(xCoord, yCoord); + points.add(point); + } + + return points; +} + +/// Calculates all [Vector2]s of a bezier curve. +/// +/// A bezier curve of [controlPoints] that say how to create this curve. +/// +/// First and last points specify the beginning and the end respectively +/// of the curve. The inner points specify the shape of the curve and +/// its turning points. +/// +/// The [step] must be between zero and one (inclusive), indicating the +/// precision to calculate the curve. +/// +/// For more information read: https://en.wikipedia.org/wiki/B%C3%A9zier_curve +List calculateBezierCurve({ + required List controlPoints, + double step = 0.001, +}) { + assert( + 0 <= step && step <= 1, + 'Step ($step) must be in range 0 <= step <= 1', + ); + assert( + controlPoints.length >= 2, + 'At least 2 control points needed to create a bezier curve', + ); + + var t = 0.0; + final n = controlPoints.length - 1; + final points = []; + + do { + var xCoord = 0.0; + var yCoord = 0.0; + for (var i = 0; i <= n; i++) { + final point = controlPoints[i]; + + xCoord += + binomial(n, i) * math.pow(1 - t, n - i) * math.pow(t, i) * point.x; + yCoord += + binomial(n, i) * math.pow(1 - t, n - i) * math.pow(t, i) * point.y; + } + points.add(Vector2(xCoord, yCoord)); + + t = t + step; + } while (t <= 1); + + return points; +} + +/// Calculates the binomial coefficient of 'n' and 'k'. +/// +/// For more information read: https://en.wikipedia.org/wiki/Binomial_coefficient +num binomial(num n, num k) { + assert(0 <= k && k <= n, 'k ($k) and n ($n) must be in range 0 <= k <= n'); + + if (k == 0 || n == k) { + return 1; + } else { + return factorial(n) / (factorial(k) * factorial(n - k)); + } +} + +/// Calculate the factorial of 'n'. +/// +/// For more information read: https://en.wikipedia.org/wiki/Factorial +num factorial(num n) { + assert(n >= 0, 'Factorial is not defined for negative number n ($n)'); + + if (n == 0 || n == 1) { + return 1; + } else { + return n * factorial(n - 1); + } +} diff --git a/packages/geometry/pubspec.yaml b/packages/geometry/pubspec.yaml new file mode 100644 index 00000000..2678cdef --- /dev/null +++ b/packages/geometry/pubspec.yaml @@ -0,0 +1,19 @@ +name: geometry +description: Helper package to calculate points of lines, arcs and curves for the pathways of the ball +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.16.0 <3.0.0" + +dependencies: + flame: ^1.0.0 + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: ^0.2.0 + test: ^1.19.2 + very_good_analysis: ^2.4.0 diff --git a/packages/geometry/test/src/geometry_test.dart b/packages/geometry/test/src/geometry_test.dart new file mode 100644 index 00000000..a3040a9c --- /dev/null +++ b/packages/geometry/test/src/geometry_test.dart @@ -0,0 +1,159 @@ +// ignore_for_file: prefer_const_constructors, cascade_invocations +import 'package:flame/extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geometry/geometry.dart'; + +class Binomial { + Binomial({required this.n, required this.k}); + + final num n; + final num k; +} + +void main() { + group('calculateArc', () { + test('returns by default 100 points as indicated by precision', () { + final points = calculateArc( + center: Vector2.zero(), + radius: 100, + angle: 90, + ); + expect(points.length, 100); + }); + + test('returns as many points as indicated by precision', () { + final points = calculateArc( + center: Vector2.zero(), + radius: 100, + angle: 90, + precision: 50, + ); + expect(points.length, 50); + }); + }); + + group('calculateBezierCurve', () { + test('fails if step not in range', () { + expect( + () => calculateBezierCurve( + controlPoints: [ + Vector2(0, 0), + Vector2(10, 10), + ], + step: 2, + ), + throwsAssertionError, + ); + }); + + test('fails if not enough control points', () { + expect( + () => calculateBezierCurve(controlPoints: [Vector2.zero()]), + throwsAssertionError, + ); + expect( + () => calculateBezierCurve(controlPoints: []), + throwsAssertionError, + ); + }); + + test('returns by default 1000 points as indicated by step', () { + final points = calculateBezierCurve( + controlPoints: [ + Vector2(0, 0), + Vector2(10, 10), + ], + ); + expect(points.length, 1000); + }); + + test('returns as many points as indicated by step', () { + final points = calculateBezierCurve( + controlPoints: [ + Vector2(0, 0), + Vector2(10, 10), + ], + step: 0.01, + ); + expect(points.length, 100); + }); + }); + + group('binomial', () { + test('fails if k is negative', () { + expect(() => binomial(1, -1), throwsAssertionError); + }); + + test('fails if n is negative', () { + expect(() => binomial(-1, 1), throwsAssertionError); + }); + + test('fails if n < k', () { + expect(() => binomial(1, 2), throwsAssertionError); + }); + + test('for a specific input gives a correct value', () { + final binomialInputsToExpected = { + Binomial(n: 0, k: 0): 1, + Binomial(n: 1, k: 0): 1, + Binomial(n: 1, k: 1): 1, + Binomial(n: 2, k: 0): 1, + Binomial(n: 2, k: 1): 2, + Binomial(n: 2, k: 2): 1, + Binomial(n: 3, k: 0): 1, + Binomial(n: 3, k: 1): 3, + Binomial(n: 3, k: 2): 3, + Binomial(n: 3, k: 3): 1, + Binomial(n: 4, k: 0): 1, + Binomial(n: 4, k: 1): 4, + Binomial(n: 4, k: 2): 6, + Binomial(n: 4, k: 3): 4, + Binomial(n: 4, k: 4): 1, + Binomial(n: 5, k: 0): 1, + Binomial(n: 5, k: 1): 5, + Binomial(n: 5, k: 2): 10, + Binomial(n: 5, k: 3): 10, + Binomial(n: 5, k: 4): 5, + Binomial(n: 5, k: 5): 1, + Binomial(n: 6, k: 0): 1, + Binomial(n: 6, k: 1): 6, + Binomial(n: 6, k: 2): 15, + Binomial(n: 6, k: 3): 20, + Binomial(n: 6, k: 4): 15, + Binomial(n: 6, k: 5): 6, + Binomial(n: 6, k: 6): 1, + }; + binomialInputsToExpected.forEach((input, value) { + expect(binomial(input.n, input.k), value); + }); + }); + }); + + group('factorial', () { + test('fails if negative number', () { + expect(() => factorial(-1), throwsAssertionError); + }); + + test('for a specific input gives a correct value', () { + final factorialInputsToExpected = { + 0: 1, + 1: 1, + 2: 2, + 3: 6, + 4: 24, + 5: 120, + 6: 720, + 7: 5040, + 8: 40320, + 9: 362880, + 10: 3628800, + 11: 39916800, + 12: 479001600, + 13: 6227020800, + }; + factorialInputsToExpected.forEach((input, expected) { + expect(factorial(input), expected); + }); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 861dae5b..4b375ff7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -198,6 +198,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + geometry: + dependency: "direct main" + description: + path: "packages/geometry" + relative: true + source: path + version: "1.0.0+1" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8738f2bb..41b0d081 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: flutter_bloc: ^8.0.1 flutter_localizations: sdk: flutter + geometry: + path: packages/geometry intl: ^0.17.0 pinball_theme: path: packages/pinball_theme diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart index 2676a286..bd669397 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -21,9 +21,9 @@ void main() { } }, expect: () => [ - const GameState(score: 0, balls: 2), - const GameState(score: 0, balls: 1), - const GameState(score: 0, balls: 0), + const GameState(score: 0, balls: 2, bonusLetters: []), + const GameState(score: 0, balls: 1, bonusLetters: []), + const GameState(score: 0, balls: 0, bonusLetters: []), ], ); }); @@ -37,8 +37,8 @@ void main() { ..add(const Scored(points: 2)) ..add(const Scored(points: 3)), expect: () => [ - const GameState(score: 2, balls: 3), - const GameState(score: 5, balls: 3), + const GameState(score: 2, balls: 3, bonusLetters: []), + const GameState(score: 5, balls: 3, bonusLetters: []), ], ); @@ -53,9 +53,55 @@ void main() { bloc.add(const Scored(points: 2)); }, expect: () => [ - const GameState(score: 0, balls: 2), - const GameState(score: 0, balls: 1), - const GameState(score: 0, balls: 0), + const GameState(score: 0, balls: 2, bonusLetters: []), + const GameState(score: 0, balls: 1, bonusLetters: []), + const GameState(score: 0, balls: 0, bonusLetters: []), + ], + ); + }); + + group('BonusLetterActivated', () { + blocTest( + 'adds the letter to the state', + build: GameBloc.new, + act: (bloc) => bloc + ..add(const BonusLetterActivated('G')) + ..add(const BonusLetterActivated('O')) + ..add(const BonusLetterActivated('O')) + ..add(const BonusLetterActivated('G')) + ..add(const BonusLetterActivated('L')) + ..add(const BonusLetterActivated('E')), + expect: () => [ + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O', 'G'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O', 'G', 'L'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O', 'G', 'L', 'E'], + ), ], ); }); diff --git a/test/game/bloc/game_event_test.dart b/test/game/bloc/game_event_test.dart index e839ab56..0e7a0f71 100644 --- a/test/game/bloc/game_event_test.dart +++ b/test/game/bloc/game_event_test.dart @@ -40,5 +40,22 @@ void main() { expect(() => Scored(points: 0), throwsAssertionError); }); }); + + group('BonusLetterActivated', () { + test('can be instantiated', () { + expect(const BonusLetterActivated('A'), isNotNull); + }); + + test('supports value equality', () { + expect( + BonusLetterActivated('A'), + equals(BonusLetterActivated('A')), + ); + expect( + BonusLetterActivated('B'), + isNot(equals(BonusLetterActivated('A'))), + ); + }); + }); }); } diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index 59cc0d1d..7345d3bd 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -7,14 +7,27 @@ void main() { group('GameState', () { test('supports value equality', () { expect( - GameState(score: 0, balls: 0), - equals(const GameState(score: 0, balls: 0)), + GameState( + score: 0, + balls: 0, + bonusLetters: const [], + ), + equals( + const GameState( + score: 0, + balls: 0, + bonusLetters: [], + ), + ), ); }); group('constructor', () { test('can be instantiated', () { - expect(const GameState(score: 0, balls: 0), isNotNull); + expect( + const GameState(score: 0, balls: 0, bonusLetters: []), + isNotNull, + ); }); }); @@ -23,7 +36,7 @@ void main() { 'when balls are negative', () { expect( - () => GameState(balls: -1, score: 0), + () => GameState(balls: -1, score: 0, bonusLetters: const []), throwsAssertionError, ); }, @@ -34,7 +47,7 @@ void main() { 'when score is negative', () { expect( - () => GameState(balls: 0, score: -1), + () => GameState(balls: 0, score: -1, bonusLetters: const []), throwsAssertionError, ); }, @@ -47,6 +60,7 @@ void main() { const gameState = GameState( balls: 0, score: 0, + bonusLetters: [], ); expect(gameState.isGameOver, isTrue); }); @@ -57,6 +71,7 @@ void main() { const gameState = GameState( balls: 1, score: 0, + bonusLetters: [], ); expect(gameState.isGameOver, isFalse); }); @@ -70,6 +85,7 @@ void main() { const gameState = GameState( balls: 1, score: 0, + bonusLetters: [], ); expect(gameState.isLastBall, isTrue); }, @@ -82,6 +98,7 @@ void main() { const gameState = GameState( balls: 2, score: 0, + bonusLetters: [], ); expect(gameState.isLastBall, isFalse); }, @@ -96,6 +113,7 @@ void main() { const gameState = GameState( balls: 0, score: 2, + bonusLetters: [], ); expect( () => gameState.copyWith(score: gameState.score - 1), @@ -111,6 +129,7 @@ void main() { const gameState = GameState( balls: 0, score: 2, + bonusLetters: [], ); expect( gameState.copyWith(), @@ -126,10 +145,12 @@ void main() { const gameState = GameState( score: 2, balls: 0, + bonusLetters: [], ); final otherGameState = GameState( score: gameState.score + 1, balls: gameState.balls + 1, + bonusLetters: const ['A'], ); expect(gameState, isNot(equals(otherGameState))); @@ -137,6 +158,7 @@ void main() { gameState.copyWith( score: otherGameState.score, balls: otherGameState.balls, + bonusLetters: otherGameState.bonusLetters, ), equals(otherGameState), ); diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index ec594cac..98e0d3fb 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -130,7 +130,11 @@ void main() { whenListen( gameBloc, const Stream.empty(), - initialState: const GameState(score: 10, balls: 1), + initialState: const GameState( + score: 10, + balls: 1, + bonusLetters: [], + ), ); await game.ready(); diff --git a/test/game/components/board_side_test.dart b/test/game/components/board_side_test.dart new file mode 100644 index 00000000..3d6d3fa1 --- /dev/null +++ b/test/game/components/board_side_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + group( + 'BoardSide', + () { + test('has two values', () { + expect(BoardSide.values.length, equals(2)); + }); + }, + ); + + group('BoardSideX', () { + test('isLeft is correct', () { + const side = BoardSide.left; + expect(side.isLeft, isTrue); + expect(side.isRight, isFalse); + }); + + test('isRight is correct', () { + const side = BoardSide.right; + expect(side.isLeft, isFalse); + expect(side.isRight, isTrue); + }); + }); +} diff --git a/test/game/components/flipper_test.dart b/test/game/components/flipper_test.dart new file mode 100644 index 00000000..b9894d9a --- /dev/null +++ b/test/game/components/flipper_test.dart @@ -0,0 +1,401 @@ +// ignore_for_file: cascade_invocations + +import 'dart:collection'; + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/services.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( + 'Flipper', + () { + flameTester.test( + 'loads correctly', + (game) async { + final leftFlipper = Flipper.left(position: Vector2.zero()); + final rightFlipper = Flipper.right(position: Vector2.zero()); + await game.ensureAddAll([leftFlipper, rightFlipper]); + + expect(game.contains(leftFlipper), isTrue); + }, + ); + + group('constructor', () { + test('sets BoardSide', () { + final leftFlipper = Flipper.left(position: Vector2.zero()); + expect(leftFlipper.side, equals(leftFlipper.side)); + + final rightFlipper = Flipper.right(position: Vector2.zero()); + expect(rightFlipper.side, equals(rightFlipper.side)); + }); + }); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final flipper = Flipper.left(position: position); + await game.ensureAdd(flipper); + game.contains(flipper); + + expect(flipper.body.position, position); + }, + ); + + flameTester.test( + 'is dynamic', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + expect(flipper.body.bodyType, equals(BodyType.dynamic)); + }, + ); + + flameTester.test( + 'ignores gravity', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + expect(flipper.body.gravityScale, isZero); + }, + ); + + flameTester.test( + 'has greater mass than Ball', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + final ball = Ball(position: Vector2.zero()); + + await game.ensureAddAll([flipper, ball]); + + expect( + flipper.body.getMassData().mass, + greaterThan(ball.body.getMassData().mass), + ); + }, + ); + }); + + group('fixtures', () { + flameTester.test( + 'has three', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + expect(flipper.body.fixtures.length, equals(3)); + }, + ); + + flameTester.test( + 'has density', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final fixtures = flipper.body.fixtures; + final density = fixtures.fold( + 0, + (sum, fixture) => sum + fixture.density, + ); + + expect(density, greaterThan(0)); + }, + ); + }); + + group('onKeyEvent', () { + final leftKeys = UnmodifiableListView([ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.keyA, + ]); + final rightKeys = UnmodifiableListView([ + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.keyD, + ]); + + group('and Flipper is left', () { + late Flipper flipper; + + setUp(() { + flipper = Flipper.left(position: Vector2.zero()); + }); + + testRawKeyDownEvents(leftKeys, (event) { + flameTester.test( + 'moves upwards ' + 'when ${event.logicalKey.keyLabel} is pressed', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isPositive); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyUpEvents(leftKeys, (event) { + flameTester.test( + 'moves downwards ' + 'when ${event.logicalKey.keyLabel} is released', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isNegative); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyUpEvents(rightKeys, (event) { + flameTester.test( + 'does nothing ' + 'when ${event.logicalKey.keyLabel} is released', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isZero); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyDownEvents(rightKeys, (event) { + flameTester.test( + 'does nothing ' + 'when ${event.logicalKey.keyLabel} is pressed', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isZero); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + }); + + group('and Flipper is right', () { + late Flipper flipper; + + setUp(() { + flipper = Flipper.right(position: Vector2.zero()); + }); + + testRawKeyDownEvents(rightKeys, (event) { + flameTester.test( + 'moves upwards ' + 'when ${event.logicalKey.keyLabel} is pressed', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isPositive); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyUpEvents(rightKeys, (event) { + flameTester.test( + 'moves downwards ' + 'when ${event.logicalKey.keyLabel} is released', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isNegative); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyUpEvents(leftKeys, (event) { + flameTester.test( + 'does nothing ' + 'when ${event.logicalKey.keyLabel} is released', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isZero); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyDownEvents(leftKeys, (event) { + flameTester.test( + 'does nothing ' + 'when ${event.logicalKey.keyLabel} is pressed', + (game) async { + await game.ensureAdd(flipper); + flipper.onKeyEvent(event, {}); + + expect(flipper.body.linearVelocity.y, isZero); + expect(flipper.body.linearVelocity.x, isZero); + }, + ); + }); + }); + }); + }, + ); + + group( + 'FlipperAnchor', + () { + flameTester.test( + 'position is at the left of the left Flipper', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); + + expect(flipperAnchor.body.position.x, equals(-Flipper.width / 2)); + }, + ); + + flameTester.test( + 'position is at the right of the right Flipper', + (game) async { + final flipper = Flipper.right(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); + + expect(flipperAnchor.body.position.x, equals(Flipper.width / 2)); + }, + ); + }, + ); + + group('FlipperAnchorRevoluteJointDef', () { + group('initializes with', () { + flameTester.test( + 'limits enabled', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); + + final jointDef = FlipperAnchorRevoluteJointDef( + flipper: flipper, + anchor: flipperAnchor, + ); + + expect(jointDef.enableLimit, isTrue); + }, + ); + + group('equal upper and lower limits', () { + flameTester.test( + 'when Flipper is left', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); + + final jointDef = FlipperAnchorRevoluteJointDef( + flipper: flipper, + anchor: flipperAnchor, + ); + + expect(jointDef.lowerAngle, equals(jointDef.upperAngle)); + }, + ); + + flameTester.test( + 'when Flipper is right', + (game) async { + final flipper = Flipper.right(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); + + final jointDef = FlipperAnchorRevoluteJointDef( + flipper: flipper, + anchor: flipperAnchor, + ); + + expect(jointDef.lowerAngle, equals(jointDef.upperAngle)); + }, + ); + }); + }); + + group( + 'unlocks', + () { + flameTester.test( + 'when Flipper is left', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); + + final jointDef = FlipperAnchorRevoluteJointDef( + flipper: flipper, + anchor: flipperAnchor, + ); + final joint = game.world.createJoint(jointDef) as RevoluteJoint; + + FlipperAnchorRevoluteJointDef.unlock(joint, flipper.side); + + expect( + joint.upperLimit, + isNot(equals(joint.lowerLimit)), + ); + }, + ); + + flameTester.test( + 'when Flipper is right', + (game) async { + final flipper = Flipper.right(position: Vector2.zero()); + await game.ensureAdd(flipper); + + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); + + final jointDef = FlipperAnchorRevoluteJointDef( + flipper: flipper, + anchor: flipperAnchor, + ); + final joint = game.world.createJoint(jointDef) as RevoluteJoint; + + FlipperAnchorRevoluteJointDef.unlock(joint, flipper.side); + + expect( + joint.upperLimit, + isNot(equals(joint.lowerLimit)), + ); + }, + ); + }, + ); + }); +} diff --git a/test/game/components/pathway_test.dart b/test/game/components/pathway_test.dart new file mode 100644 index 00000000..d3b82e96 --- /dev/null +++ b/test/game/components/pathway_test.dart @@ -0,0 +1,255 @@ +// ignore_for_file: cascade_invocations, prefer_const_constructors +import 'dart:math' as math; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGame.new); + + group('Pathway', () { + const width = 50.0; + + group('straight', () { + group('color', () { + flameTester.test( + 'has transparent color by default when no color is specified', + (game) async { + final pathway = Pathway.straight( + position: Vector2.zero(), + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + ); + await game.ensureAdd(pathway); + + expect(game.contains(pathway), isTrue); + expect(pathway.paint, isNotNull); + expect( + pathway.paint.color, + equals(Color.fromARGB(0, 0, 0, 0)), + ); + }, + ); + + flameTester.test( + 'has a color when is specified', + (game) async { + const defaultColor = Colors.blue; + + final pathway = Pathway.straight( + color: defaultColor, + position: Vector2.zero(), + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + ); + await game.ensureAdd(pathway); + + expect(game.contains(pathway), isTrue); + expect(pathway.paint, isNotNull); + expect(pathway.paint.color.value, equals(defaultColor.value)); + }, + ); + }); + + flameTester.test( + 'loads correctly', + (game) async { + final pathway = Pathway.straight( + position: Vector2.zero(), + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + ); + await game.ensureAdd(pathway); + + expect(game.contains(pathway), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final pathway = Pathway.straight( + position: position, + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + ); + await game.ensureAdd(pathway); + + game.contains(pathway); + expect(pathway.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final pathway = Pathway.straight( + position: Vector2.zero(), + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + ); + await game.ensureAdd(pathway); + + expect(pathway.body.bodyType, equals(BodyType.static)); + }, + ); + }); + + group('fixtures', () { + flameTester.test( + 'has only one ChainShape when singleWall is true', + (game) async { + final pathway = Pathway.straight( + position: Vector2.zero(), + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + singleWall: true, + ); + await game.ensureAdd(pathway); + + expect(pathway.body.fixtures.length, 1); + final fixture = pathway.body.fixtures[0]; + expect(fixture, isA()); + expect(fixture.shape.shapeType, equals(ShapeType.chain)); + }, + ); + + flameTester.test( + 'has two ChainShape when singleWall is false (default)', + (game) async { + final pathway = Pathway.straight( + position: Vector2.zero(), + start: Vector2(10, 10), + end: Vector2(20, 20), + width: width, + ); + await game.ensureAdd(pathway); + + expect(pathway.body.fixtures.length, 2); + for (final fixture in pathway.body.fixtures) { + expect(fixture, isA()); + expect(fixture.shape.shapeType, equals(ShapeType.chain)); + } + }, + ); + }); + }); + + group('arc', () { + flameTester.test( + 'loads correctly', + (game) async { + final pathway = Pathway.arc( + position: Vector2.zero(), + width: width, + radius: 100, + angle: math.pi / 2, + ); + await game.ensureAdd(pathway); + + expect(game.contains(pathway), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final pathway = Pathway.arc( + position: position, + width: width, + radius: 100, + angle: math.pi / 2, + ); + await game.ensureAdd(pathway); + + game.contains(pathway); + expect(pathway.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final pathway = Pathway.arc( + position: Vector2.zero(), + width: width, + radius: 100, + angle: math.pi / 2, + ); + await game.ensureAdd(pathway); + + expect(pathway.body.bodyType, equals(BodyType.static)); + }, + ); + }); + }); + + group('bezier curve', () { + final controlPoints = [ + Vector2(0, 0), + Vector2(50, 0), + Vector2(0, 50), + Vector2(50, 50), + ]; + + flameTester.test( + 'loads correctly', + (game) async { + final pathway = Pathway.bezierCurve( + position: Vector2.zero(), + controlPoints: controlPoints, + width: width, + ); + await game.ensureAdd(pathway); + + expect(game.contains(pathway), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final pathway = Pathway.bezierCurve( + position: position, + controlPoints: controlPoints, + width: width, + ); + await game.ensureAdd(pathway); + + game.contains(pathway); + expect(pathway.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final pathway = Pathway.bezierCurve( + position: Vector2.zero(), + controlPoints: controlPoints, + width: width, + ); + await game.ensureAdd(pathway); + + expect(pathway.body.bodyType, equals(BodyType.static)); + }, + ); + }); + }); + }); +} diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 75a77aa9..4dc93b7f 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -1,9 +1,54 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; void main() { group('PinballGame', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGame.new); + // TODO(alestiago): test if [PinballGame] registers // [BallScorePointsCallback] once the following issue is resolved: // https://github.com/flame-engine/flame/issues/1416 + group( + 'components', + () { + 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 right Flipper', + (game) async { + await game.ready(); + + expect( + () => game.children.singleWhere( + flipperSelector(BoardSide.right), + ), + returnsNormally, + ); + }, + ); + }); + }, + ); }); } diff --git a/test/game/view/game_hud_test.dart b/test/game/view/game_hud_test.dart new file mode 100644 index 00000000..e7334e41 --- /dev/null +++ b/test/game/view/game_hud_test.dart @@ -0,0 +1,79 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import '../../helpers/helpers.dart'; + +void main() { + group('GameHud', () { + late GameBloc gameBloc; + const initialState = GameState(score: 10, balls: 2, bonusLetters: []); + + void _mockState(GameState state) { + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + } + + Future _pumpHud(WidgetTester tester) async { + await tester.pumpApp( + GameHud(), + gameBloc: gameBloc, + ); + } + + setUp(() { + gameBloc = MockGameBloc(); + _mockState(initialState); + }); + + testWidgets( + 'renders the current score', + (tester) async { + await _pumpHud(tester); + expect(find.text(initialState.score.toString()), findsOneWidget); + }, + ); + + testWidgets( + 'renders the current ball number', + (tester) async { + await _pumpHud(tester); + expect( + find.byType(CircleAvatar), + findsNWidgets(initialState.balls), + ); + }, + ); + + testWidgets('updates the score', (tester) async { + await _pumpHud(tester); + expect(find.text(initialState.score.toString()), findsOneWidget); + + _mockState(initialState.copyWith(score: 20)); + + await tester.pump(); + expect(find.text('20'), findsOneWidget); + }); + + testWidgets('updates the ball number', (tester) async { + await _pumpHud(tester); + expect( + find.byType(CircleAvatar), + findsNWidgets(initialState.balls), + ); + + _mockState(initialState.copyWith(balls: 1)); + + await tester.pump(); + expect( + find.byType(CircleAvatar), + findsNWidgets(1), + ); + }); + }); +} diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index c1f1062f..fcfbe149 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -55,7 +55,7 @@ void main() { }); group('PinballGameView', () { - testWidgets('renders game', (tester) async { + testWidgets('renders game and a hud', (tester) async { final gameBloc = MockGameBloc(); whenListen( gameBloc, @@ -71,13 +71,17 @@ void main() { find.byWidgetPredicate((w) => w is GameWidget), findsOneWidget, ); + expect( + find.byType(GameHud), + findsOneWidget, + ); }); testWidgets( 'renders a game over dialog when the user has lost', (tester) async { final gameBloc = MockGameBloc(); - const state = GameState(score: 0, balls: 0); + const state = GameState(score: 0, balls: 0, bonusLetters: []); whenListen( gameBloc, Stream.value(state), diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 97bc22be..c2c1cd36 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -6,5 +6,6 @@ // https://opensource.org/licenses/MIT. export 'builders.dart'; +export 'key_testers.dart'; export 'mocks.dart'; export 'pump_app.dart'; diff --git a/test/helpers/key_testers.dart b/test/helpers/key_testers.dart new file mode 100644 index 00000000..04fed1da --- /dev/null +++ b/test/helpers/key_testers.dart @@ -0,0 +1,37 @@ +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'helpers.dart'; + +@isTest +void testRawKeyUpEvents( + List keys, + Function(RawKeyUpEvent) test, +) { + for (final key in keys) { + test(_mockKeyUpEvent(key)); + } +} + +RawKeyUpEvent _mockKeyUpEvent(LogicalKeyboardKey key) { + final event = MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn(key); + return event; +} + +@isTest +void testRawKeyDownEvents( + List keys, + Function(RawKeyDownEvent) test, +) { + for (final key in keys) { + test(_mockKeyDownEvent(key)); + } +} + +RawKeyDownEvent _mockKeyDownEvent(LogicalKeyboardKey key) { + final event = MockRawKeyDownEvent(); + when(() => event.logicalKey).thenReturn(key); + return event; +} diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 95ca9824..44e78afe 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -1,4 +1,6 @@ import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/theme/theme.dart'; @@ -16,3 +18,17 @@ class MockContact extends Mock implements Contact {} class MockGameBloc extends Mock implements GameBloc {} class MockThemeCubit extends Mock implements ThemeCubit {} + +class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} + +class MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +}