Merge branch 'main' into feat/character-selection

pull/20/head
Allison Ryan 4 years ago
commit 0c24fd9fc1

@ -1,6 +1,7 @@
{
"hosting": {
"public": "build/web",
"site": "ashehwkdkdjruejdnensjsjdne",
"ignore": [
"firebase.json",
"**/.*",

@ -9,6 +9,7 @@ class GameBloc extends Bloc<GameEvent, GameState> {
GameBloc() : super(const GameState.initial()) {
on<BallLost>(_onBallLost);
on<Scored>(_onScored);
on<BonusLetterActivated>(_onBonusLetterActivated);
}
void _onBallLost(BallLost event, Emitter emit) {
@ -22,4 +23,15 @@ class GameBloc extends Bloc<GameEvent, GameState> {
emit(state.copyWith(score: state.score + event.points));
}
}
void _onBonusLetterActivated(BonusLetterActivated event, Emitter emit) {
emit(
state.copyWith(
bonusLetters: [
...state.bonusLetters,
event.letter,
],
),
);
}
}

@ -24,3 +24,12 @@ class Scored extends GameEvent {
@override
List<Object?> get props => [points];
}
class BonusLetterActivated extends GameEvent {
const BonusLetterActivated(this.letter);
final String letter;
@override
List<Object?> get props => [letter];
}

@ -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<String> 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<String>? 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<Object?> get props => [
score,
balls,
bonusLetters,
];
}

@ -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;
}

@ -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';

@ -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<LogicalKeyboardKey> 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<LogicalKeyboardKey> _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<FixtureDef> _createFixtureDefs() {
final fixtures = <FixtureDef>[];
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<void>();
@override
void onMount() {
super.onMount();
hasMounted.complete();
}
@override
bool onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> 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);
}
}

@ -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<List<Vector2>> 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 = <List<Vector2>>[];
// 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 = <List<Vector2>>[];
// 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<Vector2> controlPoints,
required double width,
double rotation = 0,
bool singleWall = false,
}) {
final paths = <List<Vector2>>[];
// 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<List<Vector2>> _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;
}
}

@ -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

@ -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<void> onLoad() async {
addContactCallback(BallScorePointsCallback());
await add(BottomWall(this));
addContactCallback(BottomWallBallContactCallback());
unawaited(_addFlippers());
}
@override
void onAttach() {
super.onAttach();
spawnBall();
Future<void> _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,
),
),
);
}
}

@ -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<GameBloc>().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,
),
),
],
),
],
),
);
}
}

@ -61,7 +61,18 @@ class _PinballGameViewState extends State<PinballGameView> {
);
}
},
child: GameWidget<PinballGame>(game: _game),
child: Stack(
children: [
Positioned.fill(
child: GameWidget<PinballGame>(game: _game),
),
const Positioned(
top: 8,
left: 8,
child: GameHud(),
),
],
),
);
}
}

@ -1,2 +1,3 @@
export 'game_hud.dart';
export 'pinball_game_page.dart';
export 'widgets/widgets.dart';

@ -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

@ -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

@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml

@ -0,0 +1,3 @@
library geometry;
export '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<Vector2> calculateArc({
required Vector2 center,
required double radius,
required double angle,
double offsetAngle = 0,
int precision = 100,
}) {
final stepAngle = angle / (precision - 1);
final points = <Vector2>[];
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<Vector2> calculateBezierCurve({
required List<Vector2> 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 = <Vector2>[];
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);
}
}

@ -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

@ -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);
});
});
});
}

@ -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:

@ -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

@ -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<GameBloc, GameState>(
'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'],
),
],
);
});

@ -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'))),
);
});
});
});
}

@ -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),
);

@ -130,7 +130,11 @@ void main() {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState(score: 10, balls: 1),
initialState: const GameState(
score: 10,
balls: 1,
bonusLetters: [],
),
);
await game.ready();

@ -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);
});
});
}

@ -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<double>(
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)),
);
},
);
},
);
});
}

@ -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<Fixture>());
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<Fixture>());
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));
},
);
});
});
});
}

@ -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,
);
},
);
});
},
);
});
}

@ -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<void> _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),
);
});
});
}

@ -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<PinballGame>),
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),

@ -6,5 +6,6 @@
// https://opensource.org/licenses/MIT.
export 'builders.dart';
export 'key_testers.dart';
export 'mocks.dart';
export 'pump_app.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<LogicalKeyboardKey> 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<LogicalKeyboardKey> 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;
}

@ -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();
}
}

Loading…
Cancel
Save