diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index bd5f5437..89f60343 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -2,6 +2,7 @@ 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/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/pinball_game.dart b/lib/game/pinball_game.dart index 308d8faf..501ea514 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -1,5 +1,4 @@ import 'dart:async'; - import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.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/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)); + }, + ); + }); + }); + }); +}