Merge branch 'main' into fix/deactivate-bonus-word-while-effect

pull/61/head
Erick 4 years ago committed by GitHub
commit 11d1086787
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,87 @@
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/foundation.dart';
const _attachedErrorMessage = "Can't add to attached Blueprints";
// TODO(erickzanardo): Keeping this inside our code base
// so we can experiment with the idea, but this is a
// potential upstream change on Flame.
/// A [Blueprint] is a virtual way of grouping [Component]s
/// that are related, but they need to be added directly on
/// the [FlameGame] level.
abstract class Blueprint {
final List<Component> _components = [];
bool _isAttached = false;
/// Called before the the [Component]s managed
/// by this blueprint is added to the [FlameGame]
void build();
/// Attach the [Component]s built on [build] to the [game]
/// instance
@mustCallSuper
Future<void> attach(FlameGame game) async {
build();
await game.addAll(_components);
_isAttached = true;
}
/// Adds a list of [Component]s to this blueprint.
void addAll(List<Component> components) {
assert(!_isAttached, _attachedErrorMessage);
_components.addAll(components);
}
/// Adds a single [Component] to this blueprint.
void add(Component component) {
assert(!_isAttached, _attachedErrorMessage);
_components.add(component);
}
/// Returns a copy of the components built by this blueprint
List<Component> get components => List.unmodifiable(_components);
}
/// A [Blueprint] that provides additional
/// structures specific to flame_forge2d
abstract class Forge2DBlueprint extends Blueprint {
final List<ContactCallback> _callbacks = [];
/// Adds a single [ContactCallback] to this blueprint
void addContactCallback(ContactCallback callback) {
assert(!_isAttached, _attachedErrorMessage);
_callbacks.add(callback);
}
/// Adds a collection of [ContactCallback]s to this blueprint
void addAllContactCallback(List<ContactCallback> callbacks) {
assert(!_isAttached, _attachedErrorMessage);
_callbacks.addAll(callbacks);
}
@override
Future<void> attach(FlameGame game) async {
await super.attach(game);
assert(game is Forge2DGame, 'Forge2DBlueprint used outside a Forge2DGame');
for (final callback in _callbacks) {
(game as Forge2DGame).addContactCallback(callback);
}
}
/// Returns a copy of the callbacks built by this blueprint
List<ContactCallback> get callbacks => List.unmodifiable(_callbacks);
}
/// Adds helper methods regardin [Blueprint]s to [FlameGame]
extension FlameGameBlueprint on FlameGame {
/// Shortcut to attach a [Blueprint] instance to this game
/// equivalent to `MyBluepinrt().attach(game)`
Future<void> addFromBlueprint(Blueprint blueprint) async {
await blueprint.attach(this);
}
}

@ -68,4 +68,19 @@ class Ball extends BodyComponent<PinballGame> with InitialPosition, Layered {
gameRef.spawnBall(); gameRef.spawnBall();
} }
} }
/// Immediatly and completly [stop]s the ball.
///
/// The [Ball] will no longer be affected by any forces, including it's
/// weight and those emitted from collisions.
void stop() {
body.setType(BodyType.static);
}
/// Allows the [Ball] to be affected by forces.
///
/// If previously [stop]ed, the previous ball's velocity is not kept.
void resume() {
body.setType(BodyType.dynamic);
}
} }

@ -3,29 +3,27 @@ import 'package:pinball/game/game.dart';
/// {@template board} /// {@template board}
/// The main flat surface of the [PinballGame], where the [Flipper]s, /// The main flat surface of the [PinballGame], where the [Flipper]s,
/// [RoundBumper]s, [SlingShot]s are arranged. /// [RoundBumper]s, [Kicker]s are arranged.
/// {entemplate} /// {entemplate}
class Board extends Component { class Board extends Component {
/// {@macro board} /// {@macro board}
Board({required Vector2 size}) : _size = size; Board();
final Vector2 _size;
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
// TODO(alestiago): adjust positioning once sprites are added. // TODO(alestiago): adjust positioning once sprites are added.
final bottomGroup = _BottomGroup( final bottomGroup = _BottomGroup(
position: Vector2( position: Vector2(
_size.x / 2, PinballGame.boardBounds.center.dx,
_size.y / 1.25, PinballGame.boardBounds.bottom + 10,
), ),
spacing: 2, spacing: 2,
); );
final dashForest = _FlutterForest( final dashForest = _FlutterForest(
position: Vector2( position: Vector2(
_size.x / 1.25, PinballGame.boardBounds.right - 20,
_size.y / 4.25, PinballGame.boardBounds.top - 20,
), ),
); );
@ -76,7 +74,7 @@ class _FlutterForest extends Component {
/// {@template bottom_group} /// {@template bottom_group}
/// Grouping of the board's bottom [Component]s. /// Grouping of the board's bottom [Component]s.
/// ///
/// The [_BottomGroup] consists of[Flipper]s, [Baseboard]s and [SlingShot]s. /// The [_BottomGroup] consists of[Flipper]s, [Baseboard]s and [Kicker]s.
/// {@endtemplate} /// {@endtemplate}
// TODO(alestiago): Consider renaming once entire Board is defined. // TODO(alestiago): Consider renaming once entire Board is defined.
class _BottomGroup extends Component { class _BottomGroup extends Component {
@ -94,7 +92,7 @@ class _BottomGroup extends Component {
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
final spacing = this.spacing + Flipper.width / 2; final spacing = this.spacing + Flipper.size.x / 2;
final rightSide = _BottomGroupSide( final rightSide = _BottomGroupSide(
side: BoardSide.right, side: BoardSide.right,
position: position + Vector2(spacing, 0), position: position + Vector2(spacing, 0),
@ -135,17 +133,17 @@ class _BottomGroupSide extends Component {
final baseboard = Baseboard(side: _side) final baseboard = Baseboard(side: _side)
..initialPosition = _position + ..initialPosition = _position +
Vector2( Vector2(
(Flipper.width * direction) - direction, (Flipper.size.x * direction) - direction,
Flipper.height, Flipper.size.y,
); );
final slingShot = SlingShot( final kicker = Kicker(
side: _side, side: _side,
)..initialPosition = _position + )..initialPosition = _position +
Vector2( Vector2(
(Flipper.width) * direction, (Flipper.size.x) * direction,
Flipper.height + SlingShot.size.y, Flipper.size.y + Kicker.size.y,
); );
await addAll([flipper, baseboard, slingShot]); await addAll([flipper, baseboard, kicker]);
} }
} }

@ -3,7 +3,7 @@ import 'package:pinball/game/game.dart';
/// Indicates a side of the board. /// Indicates a side of the board.
/// ///
/// Usually used to position or mirror elements of a [PinballGame]; such as a /// Usually used to position or mirror elements of a [PinballGame]; such as a
/// [Flipper] or [SlingShot]. /// [Flipper] or [Kicker].
enum BoardSide { enum BoardSide {
/// The left side of the board. /// The left side of the board.
left, left,

@ -7,6 +7,7 @@ export 'flipper.dart';
export 'initial_position.dart'; export 'initial_position.dart';
export 'jetpack_ramp.dart'; export 'jetpack_ramp.dart';
export 'joint_anchor.dart'; export 'joint_anchor.dart';
export 'kicker.dart';
export 'launcher_ramp.dart'; export 'launcher_ramp.dart';
export 'layer.dart'; export 'layer.dart';
export 'pathway.dart'; export 'pathway.dart';
@ -14,6 +15,5 @@ export 'plunger.dart';
export 'ramp_opening.dart'; export 'ramp_opening.dart';
export 'round_bumper.dart'; export 'round_bumper.dart';
export 'score_points.dart'; export 'score_points.dart';
export 'sling_shot.dart';
export 'spaceship.dart'; export 'spaceship.dart';
export 'wall.dart'; export 'wall.dart';

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/input.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -58,11 +57,8 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
/// Sprite is preloaded by [PinballGameAssetsX]. /// Sprite is preloaded by [PinballGameAssetsX].
static const spritePath = 'components/flipper.png'; static const spritePath = 'components/flipper.png';
/// The width of the [Flipper]. /// The size of the [Flipper].
static const width = 12.0; static final size = Vector2(12, 2.8);
/// The height of the [Flipper].
static const height = 2.8;
/// The speed required to move the [Flipper] to its highest position. /// The speed required to move the [Flipper] to its highest position.
/// ///
@ -97,7 +93,7 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
final sprite = await gameRef.loadSprite(spritePath); final sprite = await gameRef.loadSprite(spritePath);
final spriteComponent = SpriteComponent( final spriteComponent = SpriteComponent(
sprite: sprite, sprite: sprite,
size: Vector2(width, height), size: size,
anchor: Anchor.center, anchor: Anchor.center,
); );
@ -134,21 +130,21 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
final fixturesDef = <FixtureDef>[]; final fixturesDef = <FixtureDef>[];
final isLeft = side.isLeft; final isLeft = side.isLeft;
final bigCircleShape = CircleShape()..radius = height / 2; final bigCircleShape = CircleShape()..radius = 1.75;
bigCircleShape.position.setValues( bigCircleShape.position.setValues(
isLeft isLeft
? -(width / 2) + bigCircleShape.radius ? -(size.x / 2) + bigCircleShape.radius
: (width / 2) - bigCircleShape.radius, : (size.x / 2) - bigCircleShape.radius,
0, 0,
); );
final bigCircleFixtureDef = FixtureDef(bigCircleShape); final bigCircleFixtureDef = FixtureDef(bigCircleShape);
fixturesDef.add(bigCircleFixtureDef); fixturesDef.add(bigCircleFixtureDef);
final smallCircleShape = CircleShape()..radius = bigCircleShape.radius / 2; final smallCircleShape = CircleShape()..radius = 0.9;
smallCircleShape.position.setValues( smallCircleShape.position.setValues(
isLeft isLeft
? (width / 2) - smallCircleShape.radius ? (size.x / 2) - smallCircleShape.radius
: -(width / 2) + smallCircleShape.radius, : -(size.x / 2) + smallCircleShape.radius,
0, 0,
); );
final smallCircleFixtureDef = FixtureDef(smallCircleShape); final smallCircleFixtureDef = FixtureDef(smallCircleShape);
@ -216,7 +212,7 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
} }
/// {@template flipper_anchor} /// {@template flipper_anchor}
/// [Anchor] positioned at the end of a [Flipper]. /// [JointAnchor] positioned at the end of a [Flipper].
/// ///
/// The end of a [Flipper] depends on its [Flipper.side]. /// The end of a [Flipper] depends on its [Flipper.side].
/// {@endtemplate} /// {@endtemplate}
@ -227,15 +223,15 @@ class FlipperAnchor extends JointAnchor {
}) { }) {
initialPosition = Vector2( initialPosition = Vector2(
flipper.side.isLeft flipper.side.isLeft
? flipper.body.position.x - Flipper.width / 2 ? flipper.body.position.x - Flipper.size.x / 2
: flipper.body.position.x + Flipper.width / 2, : flipper.body.position.x + Flipper.size.x / 2,
flipper.body.position.y, flipper.body.position.y,
); );
} }
} }
/// {@template flipper_anchor_revolute_joint_def} /// {@template flipper_anchor_revolute_joint_def}
/// Hinges one end of [Flipper] to a [Anchor] to achieve an arc motion. /// Hinges one end of [Flipper] to a [FlipperAnchor] to achieve an arc motion.
/// {@endtemplate} /// {@endtemplate}
class FlipperAnchorRevoluteJointDef extends RevoluteJointDef { class FlipperAnchorRevoluteJointDef extends RevoluteJointDef {
/// {@macro flipper_anchor_revolute_joint_def} /// {@macro flipper_anchor_revolute_joint_def}

@ -30,22 +30,23 @@ class JetpackRamp extends Component with HasGameRef<PinballGame> {
// TODO(ruialonso): Use a bezier curve once control points are defined. // TODO(ruialonso): Use a bezier curve once control points are defined.
color: const Color.fromARGB(255, 8, 218, 241), color: const Color.fromARGB(255, 8, 218, 241),
center: position, center: position,
width: 80, width: 5,
radius: 200, radius: 18,
angle: 7 * math.pi / 6, angle: math.pi,
rotation: -math.pi / 18, rotation: math.pi,
) )..layer = layer;
..initialPosition = position
..layer = layer;
final leftOpening = _JetpackRampOpening( final leftOpening = _JetpackRampOpening(
rotation: 15 * math.pi / 180, outsideLayer: Layer.spaceship,
rotation: math.pi,
) )
..initialPosition = position + Vector2(-27, 21) ..initialPosition = position - Vector2(2, 22)
..layer = Layer.opening; ..layer = Layer.jetpack;
final rightOpening = _JetpackRampOpening( final rightOpening = _JetpackRampOpening(
rotation: -math.pi / 20, rotation: math.pi,
) )
..initialPosition = position + Vector2(-11.2, 22.5) ..initialPosition = position - Vector2(-13, 22)
..layer = Layer.opening; ..layer = Layer.opening;
await addAll([ await addAll([
@ -63,10 +64,12 @@ class JetpackRamp extends Component with HasGameRef<PinballGame> {
class _JetpackRampOpening extends RampOpening { class _JetpackRampOpening extends RampOpening {
/// {@macro jetpack_ramp_opening} /// {@macro jetpack_ramp_opening}
_JetpackRampOpening({ _JetpackRampOpening({
Layer? outsideLayer,
required double rotation, required double rotation,
}) : _rotation = rotation, }) : _rotation = rotation,
super( super(
pathwayLayer: Layer.jetpack, pathwayLayer: Layer.jetpack,
outsideLayer: outsideLayer,
orientation: RampOrientation.down, orientation: RampOrientation.down,
); );

@ -0,0 +1,142 @@
import 'dart:math' as math;
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:geometry/geometry.dart' as geometry show centroid;
import 'package:pinball/game/game.dart';
/// {@template kicker}
/// Triangular [BodyType.static] body that propels the [Ball] towards the
/// opposite side.
///
/// [Kicker]s are usually positioned above each [Flipper].
/// {@endtemplate kicker}
class Kicker extends BodyComponent with InitialPosition {
/// {@macro kicker}
Kicker({
required BoardSide side,
}) : _side = side {
// TODO(alestiago): Use sprite instead of color when provided.
paint = Paint()
..color = const Color(0xFF00FF00)
..style = PaintingStyle.fill;
}
/// Whether the [Kicker] is on the left or right side of the board.
///
/// A [Kicker] with [BoardSide.left] propels the [Ball] to the right,
/// whereas a [Kicker] with [BoardSide.right] propels the [Ball] to the
/// left.
final BoardSide _side;
/// The size of the [Kicker] body.
// TODO(alestiago): Use size from PositionedBodyComponent instead,
// once a sprite is given.
static final Vector2 size = Vector2(4, 10);
List<FixtureDef> _createFixtureDefs() {
final fixturesDefs = <FixtureDef>[];
final direction = _side.direction;
const quarterPi = math.pi / 4;
final upperCircle = CircleShape()..radius = 1.45;
upperCircle.position.setValues(0, -upperCircle.radius / 2);
final upperCircleFixtureDef = FixtureDef(upperCircle)..friction = 0;
fixturesDefs.add(upperCircleFixtureDef);
final lowerCircle = CircleShape()..radius = 1.45;
lowerCircle.position.setValues(
size.x * -direction,
-size.y,
);
final lowerCircleFixtureDef = FixtureDef(lowerCircle)..friction = 0;
fixturesDefs.add(lowerCircleFixtureDef);
final wallFacingEdge = EdgeShape()
..set(
upperCircle.position +
Vector2(
upperCircle.radius * direction,
0,
),
// TODO(alestiago): Use values from design.
Vector2(2.0 * direction, -size.y + 2),
);
final wallFacingLineFixtureDef = FixtureDef(wallFacingEdge)..friction = 0;
fixturesDefs.add(wallFacingLineFixtureDef);
final bottomEdge = EdgeShape()
..set(
wallFacingEdge.vertex2,
lowerCircle.position +
Vector2(
lowerCircle.radius * math.cos(quarterPi) * direction,
-lowerCircle.radius * math.sin(quarterPi),
),
);
final bottomLineFixtureDef = FixtureDef(bottomEdge)..friction = 0;
fixturesDefs.add(bottomLineFixtureDef);
final bouncyEdge = EdgeShape()
..set(
upperCircle.position +
Vector2(
upperCircle.radius * math.cos(quarterPi) * -direction,
upperCircle.radius * math.sin(quarterPi),
),
lowerCircle.position +
Vector2(
lowerCircle.radius * math.cos(quarterPi) * -direction,
lowerCircle.radius * math.sin(quarterPi),
),
);
final bouncyFixtureDef = FixtureDef(bouncyEdge)
// TODO(alestiago): Play with restitution value once game is bundled.
..restitution = 10.0
..friction = 0;
fixturesDefs.add(bouncyFixtureDef);
// TODO(alestiago): Evaluate if there is value on centering the fixtures.
final centroid = geometry.centroid(
[
upperCircle.position + Vector2(0, -upperCircle.radius),
lowerCircle.position +
Vector2(
lowerCircle.radius * math.cos(quarterPi) * -direction,
-lowerCircle.radius * math.sin(quarterPi),
),
wallFacingEdge.vertex2,
],
);
for (final fixtureDef in fixturesDefs) {
fixtureDef.shape.moveBy(-centroid);
}
return fixturesDefs;
}
@override
Body createBody() {
final bodyDef = BodyDef()..position = initialPosition;
final body = world.createBody(bodyDef);
_createFixtureDefs().forEach(body.createFixture);
return body;
}
}
// TODO(alestiago): Evaluate if there's value on generalising this to
// all shapes.
extension on Shape {
void moveBy(Vector2 offset) {
if (this is CircleShape) {
final circle = this as CircleShape;
circle.position.setFrom(circle.position + offset);
} else if (this is EdgeShape) {
final edge = this as EdgeShape;
edge.set(edge.vertex1 + offset, edge.vertex2 + offset);
}
}
}

@ -28,26 +28,27 @@ class LauncherRamp extends Component with HasGameRef<PinballGame> {
final straightPath = Pathway.straight( final straightPath = Pathway.straight(
color: const Color.fromARGB(255, 34, 255, 0), color: const Color.fromARGB(255, 34, 255, 0),
start: Vector2(0, 0), start: Vector2(position.x, position.y),
end: Vector2(0, 700), end: Vector2(position.x, 74),
width: 80, width: 5,
) )
..initialPosition = position ..initialPosition = position
..layer = layer; ..layer = layer;
final curvedPath = Pathway.arc( final curvedPath = Pathway.arc(
color: const Color.fromARGB(255, 251, 255, 0), color: const Color.fromARGB(255, 251, 255, 0),
center: position + Vector2(-29, -8), center: position + Vector2(-1, 68),
radius: 300, radius: 20,
angle: 10 * math.pi / 9, angle: 8 * math.pi / 9,
width: 80, width: 5,
) rotation: math.pi,
..initialPosition = position + Vector2(-28.8, -6) )..layer = layer;
..layer = layer;
final leftOpening = _LauncherRampOpening(rotation: 13 * math.pi / 180) final leftOpening = _LauncherRampOpening(rotation: 13 * math.pi / 180)
..initialPosition = position + Vector2(-72.5, 12) ..initialPosition = position + Vector2(1, 49)
..layer = Layer.opening; ..layer = Layer.opening;
final rightOpening = _LauncherRampOpening(rotation: 0) final rightOpening = _LauncherRampOpening(rotation: 0)
..initialPosition = position + Vector2(-46.8, 17) ..initialPosition = position + Vector2(-16, 46)
..layer = Layer.opening; ..layer = Layer.opening;
await addAll([ await addAll([

@ -55,6 +55,9 @@ enum Layer {
/// Collide only with Launcher group elements. /// Collide only with Launcher group elements.
launcher, launcher,
/// Collide only with Spaceship group elements.
spaceship,
} }
/// {@template layer_mask_bits} /// {@template layer_mask_bits}
@ -81,6 +84,8 @@ extension LayerMaskBits on Layer {
return 0x0002; return 0x0002;
case Layer.launcher: case Layer.launcher:
return 0x0005; return 0x0005;
case Layer.spaceship:
return 0x000A;
} }
} }
} }

@ -150,10 +150,7 @@ class Pathway extends BodyComponent with InitialPosition, Layered {
final fixturesDef = <FixtureDef>[]; final fixturesDef = <FixtureDef>[];
for (final path in _paths) { for (final path in _paths) {
final chain = ChainShape() final chain = ChainShape()..createChain(path);
..createChain(
path.map(gameRef.screenToWorld).toList(),
);
fixturesDef.add(FixtureDef(chain)); fixturesDef.add(FixtureDef(chain));
} }

@ -1,4 +1,4 @@
import 'package:flame/input.dart'; import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';

@ -27,15 +27,21 @@ abstract class RampOpening extends BodyComponent with InitialPosition, Layered {
/// {@macro ramp_opening} /// {@macro ramp_opening}
RampOpening({ RampOpening({
required Layer pathwayLayer, required Layer pathwayLayer,
Layer? outsideLayer,
required this.orientation, required this.orientation,
}) : _pathwayLayer = pathwayLayer { }) : _pathwayLayer = pathwayLayer,
_outsideLayer = outsideLayer ?? Layer.board {
layer = Layer.board; layer = Layer.board;
} }
final Layer _pathwayLayer; final Layer _pathwayLayer;
final Layer _outsideLayer;
/// Mask of category bits for collision inside [Pathway]. /// Mask of category bits for collision inside [Pathway].
Layer get pathwayLayer => _pathwayLayer; Layer get pathwayLayer => _pathwayLayer;
/// Mask of category bits for collision outside [Pathway].
Layer get outsideLayer => _outsideLayer;
/// The [Shape] of the [RampOpening]. /// The [Shape] of the [RampOpening].
Shape get shape; Shape get shape;
@ -85,7 +91,7 @@ class RampOpeningBallContactCallback<Opening extends RampOpening>
@override @override
void end(Ball ball, Opening opening, Contact _) { void end(Ball ball, Opening opening, Contact _) {
if (!_ballsInside.contains(ball)) { if (!_ballsInside.contains(ball)) {
ball.layer = Layer.board; ball.layer = opening.outsideLayer;
} else { } else {
// TODO(ruimiguel): change this code. Check what happens with ball that // TODO(ruimiguel): change this code. Check what happens with ball that
// slightly touch Opening and goes out again. With InitialPosition change // slightly touch Opening and goes out again. With InitialPosition change
@ -97,7 +103,7 @@ class RampOpeningBallContactCallback<Opening extends RampOpening>
ball.body.linearVelocity.y > 0); ball.body.linearVelocity.y > 0);
if (isBallOutsideOpening) { if (isBallOutsideOpening) {
ball.layer = Layer.board; ball.layer = opening.outsideLayer;
_ballsInside.remove(ball); _ballsInside.remove(ball);
} }
} }

@ -1,92 +0,0 @@
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:geometry/geometry.dart' show centroid;
import 'package:pinball/game/game.dart';
/// {@template sling_shot}
/// Triangular [BodyType.static] body that propels the [Ball] towards the
/// opposite side.
///
/// [SlingShot]s are usually positioned above each [Flipper].
/// {@endtemplate sling_shot}
class SlingShot extends BodyComponent with InitialPosition {
/// {@macro sling_shot}
SlingShot({
required BoardSide side,
}) : _side = side {
// TODO(alestiago): Use sprite instead of color when provided.
paint = Paint()
..color = const Color(0xFF00FF00)
..style = PaintingStyle.fill;
}
/// Whether the [SlingShot] is on the left or right side of the board.
///
/// A [SlingShot] with [BoardSide.left] propels the [Ball] to the right,
/// whereas a [SlingShot] with [BoardSide.right] propels the [Ball] to the
/// left.
final BoardSide _side;
/// The size of the [SlingShot] body.
// TODO(alestiago): Use size from PositionedBodyComponent instead,
// once a sprite is given.
static final Vector2 size = Vector2(6, 8);
List<FixtureDef> _createFixtureDefs() {
final fixturesDef = <FixtureDef>[];
// TODO(alestiago): This magic number can be deduced by specifying the
// angle and using polar coordinate system to place the bottom right
// vertex.
// Something as: y = -size.y * math.cos(angle)
const additionalIncrement = 3;
final triangleVertices = _side.isLeft
? [
Vector2(0, 0),
Vector2(0, -size.y),
Vector2(
size.x,
-size.y - additionalIncrement,
),
]
: [
Vector2(size.x, 0),
Vector2(size.x, -size.y),
Vector2(
0,
-size.y - additionalIncrement,
),
];
final triangleCentroid = centroid(triangleVertices);
for (final vertex in triangleVertices) {
vertex.setFrom(vertex - triangleCentroid);
}
final triangle = PolygonShape()..set(triangleVertices);
final triangleFixtureDef = FixtureDef(triangle)..friction = 0;
fixturesDef.add(triangleFixtureDef);
final kicker = EdgeShape()
..set(
triangleVertices.first,
triangleVertices.last,
);
// TODO(alestiago): Play with restitution value once game is bundled.
final kickerFixtureDef = FixtureDef(kicker)
..restitution = 10.0
..friction = 0;
fixturesDef.add(kickerFixtureDef);
return fixturesDef;
}
@override
Body createBody() {
final bodyDef = BodyDef()..position = initialPosition;
final body = world.createBody(bodyDef);
_createFixtureDefs().forEach(body.createFixture);
return body;
}
}

@ -5,19 +5,46 @@ import 'dart:math';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
// TODO(erickzanardo): change this to use the layer class /// A [Blueprint] which creates the spaceship feature.
// that will be introduced on the path PR class Spaceship extends Forge2DBlueprint {
const _spaceShipBits = 0x0002; /// Total size of the spaceship
const _spaceShipSize = 20.0; static const radius = 10.0;
@override
void build() {
final position = Vector2(
PinballGame.boardBounds.left + radius + 0.5,
PinballGame.boardBounds.center.dy + 34,
);
addAllContactCallback([
SpaceshipHoleBallContactCallback(),
SpaceshipEntranceBallContactCallback(),
]);
addAll([
SpaceshipSaucer()..initialPosition = position,
SpaceshipEntrance()..initialPosition = position,
SpaceshipBridge()..initialPosition = position,
SpaceshipBridgeTop()..initialPosition = position + Vector2(0, 5.5),
SpaceshipHole()..initialPosition = position - Vector2(5, 4),
SpaceshipHole()..initialPosition = position - Vector2(-5, 4),
SpaceshipWall()..initialPosition = position,
]);
}
}
/// {@template spaceship_saucer} /// {@template spaceship_saucer}
/// A [BodyComponent] for the base, or the saucer of the spaceship /// A [BodyComponent] for the base, or the saucer of the spaceship
/// {@endtemplate} /// {@endtemplate}
class SpaceshipSaucer extends BodyComponent with InitialPosition { class SpaceshipSaucer extends BodyComponent with InitialPosition, Layered {
/// {@macro spaceship_saucer} /// {@macro spaceship_saucer}
SpaceshipSaucer() : super(priority: 2); SpaceshipSaucer() : super(priority: 2) {
layer = Layer.spaceship;
}
/// Path for the base sprite /// Path for the base sprite
static const saucerSpritePath = 'components/spaceship/saucer.png'; static const saucerSpritePath = 'components/spaceship/saucer.png';
@ -36,7 +63,7 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition {
await add( await add(
SpriteComponent( SpriteComponent(
sprite: sprites.first, sprite: sprites.first,
size: Vector2.all(_spaceShipSize), size: Vector2.all(Spaceship.radius * 2),
anchor: Anchor.center, anchor: Anchor.center,
), ),
); );
@ -44,9 +71,9 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition {
await add( await add(
SpriteComponent( SpriteComponent(
sprite: sprites.last, sprite: sprites.last,
size: Vector2(_spaceShipSize + 0.5, _spaceShipSize / 2), size: Vector2((Spaceship.radius * 2) + 0.5, Spaceship.radius),
anchor: Anchor.center, anchor: Anchor.center,
position: Vector2(0, -(_spaceShipSize / 3.5)), position: Vector2(0, -((Spaceship.radius * 2) / 3.5)),
), ),
); );
@ -55,7 +82,7 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition {
@override @override
Body createBody() { Body createBody() {
final circleShape = CircleShape()..radius = _spaceShipSize / 2; final circleShape = CircleShape()..radius = Spaceship.radius;
final bodyDef = BodyDef() final bodyDef = BodyDef()
..userData = this ..userData = this
@ -64,10 +91,7 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition {
return world.createBody(bodyDef) return world.createBody(bodyDef)
..createFixture( ..createFixture(
FixtureDef(circleShape) FixtureDef(circleShape)..isSensor = true,
..isSensor = true
..filter.maskBits = _spaceShipBits
..filter.categoryBits = _spaceShipBits,
); );
} }
} }
@ -92,7 +116,7 @@ class SpaceshipBridgeTop extends BodyComponent with InitialPosition {
SpriteComponent( SpriteComponent(
sprite: sprite, sprite: sprite,
anchor: Anchor.center, anchor: Anchor.center,
size: Vector2(_spaceShipSize / 2.5 - 1, _spaceShipSize / 5), size: Vector2((Spaceship.radius * 2) / 2.5 - 1, Spaceship.radius / 2.5),
), ),
); );
} }
@ -112,9 +136,11 @@ class SpaceshipBridgeTop extends BodyComponent with InitialPosition {
/// The main part of the [SpaceshipBridge], this [BodyComponent] /// The main part of the [SpaceshipBridge], this [BodyComponent]
/// provides both the collision and the rotation animation for the bridge. /// provides both the collision and the rotation animation for the bridge.
/// {@endtemplate} /// {@endtemplate}
class SpaceshipBridge extends BodyComponent with InitialPosition { class SpaceshipBridge extends BodyComponent with InitialPosition, Layered {
/// {@macro spaceship_bridge} /// {@macro spaceship_bridge}
SpaceshipBridge() : super(priority: 3); SpaceshipBridge() : super(priority: 3) {
layer = Layer.spaceship;
}
/// Path to the spaceship bridge /// Path to the spaceship bridge
static const spritePath = 'components/spaceship/android-bottom.png'; static const spritePath = 'components/spaceship/android-bottom.png';
@ -134,7 +160,7 @@ class SpaceshipBridge extends BodyComponent with InitialPosition {
stepTime: 0.2, stepTime: 0.2,
textureSize: Vector2(160, 114), textureSize: Vector2(160, 114),
), ),
size: Vector2.all(_spaceShipSize / 2.5), size: Vector2.all((Spaceship.radius * 2) / 2.5),
anchor: Anchor.center, anchor: Anchor.center,
), ),
); );
@ -142,7 +168,7 @@ class SpaceshipBridge extends BodyComponent with InitialPosition {
@override @override
Body createBody() { Body createBody() {
final circleShape = CircleShape()..radius = _spaceShipSize / 5; final circleShape = CircleShape()..radius = Spaceship.radius / 2.5;
final bodyDef = BodyDef() final bodyDef = BodyDef()
..userData = this ..userData = this
@ -151,10 +177,7 @@ class SpaceshipBridge extends BodyComponent with InitialPosition {
return world.createBody(bodyDef) return world.createBody(bodyDef)
..createFixture( ..createFixture(
FixtureDef(circleShape) FixtureDef(circleShape)..restitution = 0.4,
..restitution = 0.4
..filter.maskBits = _spaceShipBits
..filter.categoryBits = _spaceShipBits,
); );
} }
} }
@ -164,35 +187,29 @@ class SpaceshipBridge extends BodyComponent with InitialPosition {
/// the spaceship area in order to modify its filter data so the ball /// the spaceship area in order to modify its filter data so the ball
/// can correctly collide only with the Spaceship /// can correctly collide only with the Spaceship
/// {@endtemplate} /// {@endtemplate}
// TODO(erickzanardo): Use RampOpening once provided. class SpaceshipEntrance extends RampOpening {
class SpaceshipEntrance extends BodyComponent with InitialPosition {
/// {@macro spaceship_entrance} /// {@macro spaceship_entrance}
SpaceshipEntrance(); SpaceshipEntrance()
: super(
pathwayLayer: Layer.spaceship,
orientation: RampOrientation.up,
) {
layer = Layer.spaceship;
}
@override @override
Body createBody() { Shape get shape {
const radius = _spaceShipSize / 2; const radius = Spaceship.radius * 2;
final entranceShape = PolygonShape() return PolygonShape()
..setAsEdge( ..setAsEdge(
Vector2( Vector2(
radius * cos(20 * pi / 180), radius * cos(20 * pi / 180),
radius * sin(20 * pi / 180), radius * sin(20 * pi / 180),
), )..rotate(90 * pi / 180),
Vector2( Vector2(
radius * cos(340 * pi / 180), radius * cos(340 * pi / 180),
radius * sin(340 * pi / 180), radius * sin(340 * pi / 180),
), )..rotate(90 * pi / 180),
);
final bodyDef = BodyDef()
..userData = this
..position = initialPosition
..angle = 90 * pi / 180
..type = BodyType.static;
return world.createBody(bodyDef)
..createFixture(
FixtureDef(entranceShape)..isSensor = true,
); );
} }
} }
@ -201,14 +218,16 @@ class SpaceshipEntrance extends BodyComponent with InitialPosition {
/// A sensor [BodyComponent] responsible for sending the [Ball] /// A sensor [BodyComponent] responsible for sending the [Ball]
/// back to the board. /// back to the board.
/// {@endtemplate} /// {@endtemplate}
class SpaceshipHole extends BodyComponent with InitialPosition { class SpaceshipHole extends BodyComponent with InitialPosition, Layered {
/// {@macro spaceship_hole} /// {@macro spaceship_hole}
SpaceshipHole(); SpaceshipHole() {
layer = Layer.spaceship;
}
@override @override
Body createBody() { Body createBody() {
renderBody = false; renderBody = false;
final circleShape = CircleShape()..radius = _spaceShipSize / 80; final circleShape = CircleShape()..radius = Spaceship.radius / 40;
final bodyDef = BodyDef() final bodyDef = BodyDef()
..userData = this ..userData = this
@ -217,10 +236,7 @@ class SpaceshipHole extends BodyComponent with InitialPosition {
return world.createBody(bodyDef) return world.createBody(bodyDef)
..createFixture( ..createFixture(
FixtureDef(circleShape) FixtureDef(circleShape)..isSensor = true,
..isSensor = true
..filter.maskBits = _spaceShipBits
..filter.categoryBits = _spaceShipBits,
); );
} }
} }
@ -231,9 +247,11 @@ class SpaceshipHole extends BodyComponent with InitialPosition {
/// [Ball] to get inside the spaceship saucer. /// [Ball] to get inside the spaceship saucer.
/// It also contains the [SpriteComponent] for the lower wall /// It also contains the [SpriteComponent] for the lower wall
/// {@endtemplate} /// {@endtemplate}
class SpaceshipWall extends BodyComponent with InitialPosition { class SpaceshipWall extends BodyComponent with InitialPosition, Layered {
/// {@macro spaceship_wall} /// {@macro spaceship_wall}
SpaceshipWall() : super(priority: 4); SpaceshipWall() : super(priority: 4) {
layer = Layer.spaceship;
}
/// Sprite path for the lower wall /// Sprite path for the lower wall
static const lowerWallPath = 'components/spaceship/lower.png'; static const lowerWallPath = 'components/spaceship/lower.png';
@ -247,9 +265,9 @@ class SpaceshipWall extends BodyComponent with InitialPosition {
await add( await add(
SpriteComponent( SpriteComponent(
sprite: sprite, sprite: sprite,
size: Vector2(_spaceShipSize, (_spaceShipSize / 2) + 1), size: Vector2(Spaceship.radius * 2, Spaceship.radius + 1),
anchor: Anchor.center, anchor: Anchor.center,
position: Vector2(-_spaceShipSize / 4, 0), position: Vector2(-Spaceship.radius / 2, 0),
angle: 90 * pi / 180, angle: 90 * pi / 180,
), ),
); );
@ -259,15 +277,13 @@ class SpaceshipWall extends BodyComponent with InitialPosition {
Body createBody() { Body createBody() {
renderBody = false; renderBody = false;
const radius = _spaceShipSize / 2;
final wallShape = ChainShape() final wallShape = ChainShape()
..createChain( ..createChain(
[ [
for (var angle = 20; angle <= 340; angle++) for (var angle = 20; angle <= 340; angle++)
Vector2( Vector2(
radius * cos(angle * pi / 180), Spaceship.radius * cos(angle * pi / 180),
radius * sin(angle * pi / 180), Spaceship.radius * sin(angle * pi / 180),
), ),
], ],
); );
@ -280,10 +296,7 @@ class SpaceshipWall extends BodyComponent with InitialPosition {
return world.createBody(bodyDef) return world.createBody(bodyDef)
..createFixture( ..createFixture(
FixtureDef(wallShape) FixtureDef(wallShape)..restitution = 1,
..restitution = 1
..filter.maskBits = _spaceShipBits
..filter.categoryBits = _spaceShipBits,
); );
} }
} }
@ -293,19 +306,14 @@ class SpaceshipWall extends BodyComponent with InitialPosition {
/// ///
/// It modifies the [Ball] priority and filter data so it can appear on top of /// It modifies the [Ball] priority and filter data so it can appear on top of
/// the spaceship and also only collide with the spaceship. /// the spaceship and also only collide with the spaceship.
// TODO(alestiago): modify once Layer is implemented in Spaceship.
class SpaceshipEntranceBallContactCallback class SpaceshipEntranceBallContactCallback
extends ContactCallback<SpaceshipEntrance, Ball> { extends ContactCallback<SpaceshipEntrance, Ball> {
@override @override
void begin(SpaceshipEntrance entrance, Ball ball, _) { void begin(SpaceshipEntrance entrance, Ball ball, _) {
ball ball
..priority = 3 ..priority = 3
..gameRef.reorderChildren(); ..gameRef.reorderChildren()
..layer = Layer.spaceship;
for (final fixture in ball.body.fixtures) {
fixture.filterData.categoryBits = _spaceShipBits;
fixture.filterData.maskBits = _spaceShipBits;
}
} }
} }
@ -314,18 +322,13 @@ class SpaceshipEntranceBallContactCallback
/// ///
/// It resets the [Ball] priority and filter data so it will "be back" on the /// It resets the [Ball] priority and filter data so it will "be back" on the
/// board. /// board.
// TODO(alestiago): modify once Layer is implemented in Spaceship.
class SpaceshipHoleBallContactCallback class SpaceshipHoleBallContactCallback
extends ContactCallback<SpaceshipHole, Ball> { extends ContactCallback<SpaceshipHole, Ball> {
@override @override
void begin(SpaceshipHole hole, Ball ball, _) { void begin(SpaceshipHole hole, Ball ball, _) {
ball ball
..priority = 1 ..priority = 1
..gameRef.reorderChildren(); ..gameRef.reorderChildren()
..layer = Layer.board;
for (final fixture in ball.body.fixtures) {
fixture.filterData.categoryBits = 0xFFFF;
fixture.filterData.maskBits = 0x0001;
}
} }
} }

@ -1,7 +1,9 @@
// ignore_for_file: avoid_renaming_method_parameters // ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/components/components.dart'; import 'package:pinball/game/components/components.dart';
import 'package:pinball/game/pinball_game.dart';
/// {@template wall} /// {@template wall}
/// A continuous generic and [BodyType.static] barrier that divides a game area. /// A continuous generic and [BodyType.static] barrier that divides a game area.
@ -39,15 +41,16 @@ class Wall extends BodyComponent {
/// Create top, left, and right [Wall]s for the game board. /// Create top, left, and right [Wall]s for the game board.
List<Wall> createBoundaries(Forge2DGame game) { List<Wall> createBoundaries(Forge2DGame game) {
final topLeft = Vector2.zero(); final topLeft = PinballGame.boardBounds.topLeft.toVector2();
final bottomRight = game.screenToWorld(game.camera.viewport.effectiveSize); final bottomRight = PinballGame.boardBounds.bottomRight.toVector2();
final topRight = Vector2(bottomRight.x, topLeft.y); final topRight = Vector2(bottomRight.x, topLeft.y);
final bottomLeft = Vector2(topLeft.x, bottomRight.y); final bottomLeft = Vector2(topLeft.x, bottomRight.y);
return [ return [
Wall(start: topLeft, end: topRight), Wall(start: topLeft, end: topRight),
Wall(start: topRight, end: bottomRight), Wall(start: topRight, end: bottomRight),
Wall(start: bottomLeft, end: topLeft), Wall(start: topLeft, end: bottomLeft),
]; ];
} }
@ -59,13 +62,10 @@ List<Wall> createBoundaries(Forge2DGame game) {
/// {@endtemplate} /// {@endtemplate}
class BottomWall extends Wall { class BottomWall extends Wall {
/// {@macro bottom_wall} /// {@macro bottom_wall}
BottomWall(Forge2DGame game) BottomWall()
: super( : super(
start: game.screenToWorld(game.camera.viewport.effectiveSize), start: PinballGame.boardBounds.bottomLeft.toVector2(),
end: Vector2( end: PinballGame.boardBounds.bottomRight.toVector2(),
0,
game.screenToWorld(game.camera.viewport.effectiveSize).y,
),
); );
} }

@ -1,9 +1,11 @@
// ignore_for_file: public_member_api_docs // ignore_for_file: public_member_api_docs
import 'dart:async'; import 'dart:async';
import 'package:flame/extensions.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_theme/pinball_theme.dart';
@ -15,6 +17,13 @@ class PinballGame extends Forge2DGame
late final Plunger plunger; late final Plunger plunger;
static final boardSize = Vector2(72, 128);
static final boardBounds = Rect.fromCenter(
center: Offset.zero,
width: boardSize.x,
height: -boardSize.y,
);
@override @override
void onAttach() { void onAttach() {
super.onAttach(); super.onAttach();
@ -26,129 +35,77 @@ class PinballGame extends Forge2DGame
_addContactCallbacks(); _addContactCallbacks();
await _addGameBoundaries(); await _addGameBoundaries();
unawaited(add(Board()));
unawaited(_addPlunger()); unawaited(_addPlunger());
unawaited(_addBonusWord());
unawaited(_addPaths()); unawaited(_addPaths());
unawaited(addFromBlueprint(Spaceship()));
unawaited(_addSpaceship()); // Fix camera on the center of the board size
camera
..followVector2(screenToWorld(boardSize / 2))
..zoom = size.y / 14;
}
// Corner wall above plunger so the ball deflects into the rest of the void _addContactCallbacks() {
// board. addContactCallback(BallScorePointsCallback());
// TODO(allisonryan0002): remove once we have the launch track for the ball. addContactCallback(BottomWallBallContactCallback());
await add( addContactCallback(BonusLetterBallContactCallback());
Wall( }
start: screenToWorld(
Vector2(
camera.viewport.effectiveSize.x,
100,
),
),
end: screenToWorld(
Vector2(
camera.viewport.effectiveSize.x - 100,
0,
),
),
),
);
unawaited(_addBonusWord()); Future<void> _addGameBoundaries() async {
unawaited(_addBoard()); await add(BottomWall());
createBoundaries(this).forEach(add);
} }
Future<void> _addBoard() async { Future<void> _addPlunger() async {
final board = Board( plunger = Plunger(compressionDistance: 2);
size: screenToWorld(
plunger.initialPosition = boardBounds.bottomRight.toVector2() -
Vector2( Vector2(
camera.viewport.effectiveSize.x, 8,
camera.viewport.effectiveSize.y, -10,
), );
), await add(plunger);
);
await add(board);
} }
Future<void> _addBonusWord() async { Future<void> _addBonusWord() async {
await add( await add(
BonusWord( BonusWord(
position: screenToWorld( position: Vector2(
Vector2( boardBounds.center.dx,
camera.viewport.effectiveSize.x / 2, boardBounds.bottom + 10,
camera.viewport.effectiveSize.y - 50,
),
), ),
), ),
); );
} }
Future<void> _addSpaceship() async {
final position = Vector2(20, -24);
await addAll(
[
SpaceshipSaucer()..initialPosition = position,
SpaceshipEntrance()..initialPosition = position,
SpaceshipBridge()..initialPosition = position,
SpaceshipBridgeTop()..initialPosition = position + Vector2(0, 5.5),
SpaceshipHole()..initialPosition = position - Vector2(5, 4),
SpaceshipHole()..initialPosition = position - Vector2(-5, 4),
SpaceshipWall()..initialPosition = position,
],
);
}
void spawnBall() {
final ball = Ball();
add(
ball
..initialPosition = plunger.body.position + Vector2(0, ball.size.y / 2),
);
}
void _addContactCallbacks() {
addContactCallback(BallScorePointsCallback());
addContactCallback(BottomWallBallContactCallback());
addContactCallback(BonusLetterBallContactCallback());
addContactCallback(SpaceshipHoleBallContactCallback());
addContactCallback(SpaceshipEntranceBallContactCallback());
}
Future<void> _addGameBoundaries() async {
await add(BottomWall(this));
createBoundaries(this).forEach(add);
}
Future<void> _addPaths() async { Future<void> _addPaths() async {
final jetpackRamp = JetpackRamp( final jetpackRamp = JetpackRamp(
position: screenToWorld( position: Vector2(
Vector2( PinballGame.boardBounds.left + 25,
camera.viewport.effectiveSize.x / 2 - 150, PinballGame.boardBounds.top - 20,
camera.viewport.effectiveSize.y / 2 - 250,
),
), ),
); );
final launcherRamp = LauncherRamp( final launcherRamp = LauncherRamp(
position: screenToWorld( position: Vector2(
Vector2( PinballGame.boardBounds.right - 23,
camera.viewport.effectiveSize.x / 2 + 400, PinballGame.boardBounds.bottom + 40,
camera.viewport.effectiveSize.y / 2 - 330,
),
), ),
); );
await addAll([jetpackRamp, launcherRamp]); await addAll([
jetpackRamp,
launcherRamp,
]);
} }
Future<void> _addPlunger() async { void spawnBall() {
plunger = Plunger( final ball = Ball();
compressionDistance: camera.viewport.effectiveSize.y / 12, add(
); ball
plunger.initialPosition = screenToWorld( ..initialPosition = plunger.body.position + Vector2(0, ball.size.y / 2),
Vector2(
camera.viewport.effectiveSize.x / 2 + 450,
camera.viewport.effectiveSize.y - plunger.compressionDistance,
),
); );
await add(plunger);
} }
} }

@ -0,0 +1,64 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
part 'leaderboard_event.dart';
part 'leaderboard_state.dart';
/// {@template leaderboard_bloc}
/// Manages leaderboard events.
///
/// Uses a [LeaderboardRepository] to request and update players participations.
/// {@endtemplate}
class LeaderboardBloc extends Bloc<LeaderboardEvent, LeaderboardState> {
/// {@macro leaderboard_bloc}
LeaderboardBloc(this._leaderboardRepository)
: super(const LeaderboardState.initial()) {
on<Top10Fetched>(_onTop10Fetched);
on<LeaderboardEntryAdded>(_onLeaderboardEntryAdded);
}
final LeaderboardRepository _leaderboardRepository;
Future<void> _onTop10Fetched(
Top10Fetched event,
Emitter<LeaderboardState> emit,
) async {
emit(state.copyWith(status: LeaderboardStatus.loading));
try {
final top10Leaderboard =
await _leaderboardRepository.fetchTop10Leaderboard();
emit(
state.copyWith(
status: LeaderboardStatus.success,
leaderboard: top10Leaderboard,
),
);
} catch (error) {
emit(state.copyWith(status: LeaderboardStatus.error));
addError(error);
}
}
Future<void> _onLeaderboardEntryAdded(
LeaderboardEntryAdded event,
Emitter<LeaderboardState> emit,
) async {
emit(state.copyWith(status: LeaderboardStatus.loading));
try {
final ranking =
await _leaderboardRepository.addLeaderboardEntry(event.entry);
emit(
state.copyWith(
status: LeaderboardStatus.success,
ranking: ranking,
),
);
} catch (error) {
emit(state.copyWith(status: LeaderboardStatus.error));
addError(error);
}
}
}

@ -0,0 +1,36 @@
part of 'leaderboard_bloc.dart';
/// {@template leaderboard_event}
/// Represents the events available for [LeaderboardBloc].
/// {endtemplate}
abstract class LeaderboardEvent extends Equatable {
/// {@macro leaderboard_event}
const LeaderboardEvent();
}
/// {@template top_10_fetched}
/// Request the top 10 [LeaderboardEntry]s.
/// {endtemplate}
class Top10Fetched extends LeaderboardEvent {
/// {@macro top_10_fetched}
const Top10Fetched();
@override
List<Object?> get props => [];
}
/// {@template leaderboard_entry_added}
/// Writes a new [LeaderboardEntry].
///
/// Should be added when a player finishes a game.
/// {endtemplate}
class LeaderboardEntryAdded extends LeaderboardEvent {
/// {@macro leaderboard_entry_added}
const LeaderboardEntryAdded({required this.entry});
/// [LeaderboardEntry] to be written to the remote storage.
final LeaderboardEntry entry;
@override
List<Object?> get props => [entry];
}

@ -0,0 +1,59 @@
// ignore_for_file: public_member_api_docs
part of 'leaderboard_bloc.dart';
/// Defines the request status.
enum LeaderboardStatus {
/// Request is being loaded.
loading,
/// Request was processed successfully and received a valid response.
success,
/// Request was processed unsuccessfully and received an error.
error,
}
/// {@template leaderboard_state}
/// Represents the state of the leaderboard.
/// {@endtemplate}
class LeaderboardState extends Equatable {
/// {@macro leaderboard_state}
const LeaderboardState({
required this.status,
required this.ranking,
required this.leaderboard,
});
const LeaderboardState.initial()
: status = LeaderboardStatus.loading,
ranking = const LeaderboardRanking(
ranking: 0,
outOf: 0,
),
leaderboard = const [];
/// The current [LeaderboardStatus] of the state.
final LeaderboardStatus status;
/// Rank of the current player.
final LeaderboardRanking ranking;
/// List of top-ranked players.
final List<LeaderboardEntry> leaderboard;
@override
List<Object> get props => [status, ranking, leaderboard];
LeaderboardState copyWith({
LeaderboardStatus? status,
LeaderboardRanking? ranking,
List<LeaderboardEntry>? leaderboard,
}) {
return LeaderboardState(
status: status ?? this.status,
ranking: ranking ?? this.ranking,
leaderboard: leaderboard ?? this.leaderboard,
);
}
}

@ -0,0 +1 @@
export 'bloc/leaderboard_bloc.dart';

@ -182,21 +182,21 @@ packages:
name: flame name: flame
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.0-releasecandidate.5" version: "1.1.0-releasecandidate.6"
flame_bloc: flame_bloc:
dependency: "direct main" dependency: "direct main"
description: description:
name: flame_bloc name: flame_bloc
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.0-releasecandidate.5" version: "1.2.0-releasecandidate.6"
flame_forge2d: flame_forge2d:
dependency: "direct main" dependency: "direct main"
description: description:
name: flame_forge2d name: flame_forge2d
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.9.0-releasecandidate.5" version: "0.9.0-releasecandidate.6"
flame_test: flame_test:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -237,7 +237,7 @@ packages:
name: forge2d name: forge2d
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.8.2" version: "0.9.0"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:

@ -10,9 +10,9 @@ dependencies:
bloc: ^8.0.2 bloc: ^8.0.2
cloud_firestore: ^3.1.10 cloud_firestore: ^3.1.10
equatable: ^2.0.3 equatable: ^2.0.3
flame: ^1.1.0-releasecandidate.5 flame: ^1.1.0-releasecandidate.6
flame_bloc: ^1.2.0-releasecandidate.5 flame_bloc: ^1.2.0-releasecandidate.6
flame_forge2d: ^0.9.0-releasecandidate.5 flame_forge2d: ^0.9.0-releasecandidate.6
flutter: flutter:
sdk: flutter sdk: flutter
flutter_bloc: ^8.0.1 flutter_bloc: ^8.0.1

@ -0,0 +1,103 @@
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart';
import '../helpers/helpers.dart';
class MyBlueprint extends Blueprint {
@override
void build() {
add(Component());
addAll([Component(), Component()]);
}
}
class MyForge2dBlueprint extends Forge2DBlueprint {
@override
void build() {
addContactCallback(MockContactCallback());
addAllContactCallback([MockContactCallback(), MockContactCallback()]);
}
}
void main() {
group('Blueprint', () {
test('components can be added to it', () {
final blueprint = MyBlueprint()..build();
expect(blueprint.components.length, equals(3));
});
test('adds the components to a game on attach', () {
final mockGame = MockPinballGame();
when(() => mockGame.addAll(any())).thenAnswer((_) async {});
MyBlueprint().attach(mockGame);
verify(() => mockGame.addAll(any())).called(1);
});
test(
'throws assertion error when adding to an already attached blueprint',
() async {
final mockGame = MockPinballGame();
when(() => mockGame.addAll(any())).thenAnswer((_) async {});
final blueprint = MyBlueprint();
await blueprint.attach(mockGame);
expect(() => blueprint.add(Component()), throwsAssertionError);
expect(() => blueprint.addAll([Component()]), throwsAssertionError);
},
);
});
group('Forge2DBlueprint', () {
setUpAll(() {
registerFallbackValue(SpaceshipHoleBallContactCallback());
});
test('callbacks can be added to it', () {
final blueprint = MyForge2dBlueprint()..build();
expect(blueprint.callbacks.length, equals(3));
});
test('adds the callbacks to a game on attach', () async {
final mockGame = MockPinballGame();
when(() => mockGame.addAll(any())).thenAnswer((_) async {});
when(() => mockGame.addContactCallback(any())).thenAnswer((_) async {});
await MyForge2dBlueprint().attach(mockGame);
verify(() => mockGame.addContactCallback(any())).called(3);
});
test(
'throws assertion error when adding to an already attached blueprint',
() async {
final mockGame = MockPinballGame();
when(() => mockGame.addAll(any())).thenAnswer((_) async {});
when(() => mockGame.addContactCallback(any())).thenAnswer((_) async {});
final blueprint = MyForge2dBlueprint();
await blueprint.attach(mockGame);
expect(
() => blueprint.addContactCallback(MockContactCallback()),
throwsAssertionError,
);
expect(
() => blueprint.addAllContactCallback([MockContactCallback()]),
throwsAssertionError,
);
},
);
test('throws assertion error when used on a non Forge2dGame', () {
expect(
() => MyForge2dBlueprint().attach(FlameGame()),
throwsAssertionError,
);
});
});
}

@ -35,6 +35,26 @@ void main() {
expect(ball.body.bodyType, equals(BodyType.dynamic)); expect(ball.body.bodyType, equals(BodyType.dynamic));
}, },
); );
group('can be moved', () {
flameTester.test('by its weight', (game) async {
final ball = Ball();
await game.ensureAdd(ball);
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
});
flameTester.test('by applying velocity', (game) async {
final ball = Ball();
await game.ensureAdd(ball);
ball.body.gravityScale = 0;
ball.body.linearVelocity.setValues(10, 10);
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
});
});
}); });
group('fixture', () { group('fixture', () {
@ -75,8 +95,9 @@ void main() {
'has Layer.all as default filter maskBits', 'has Layer.all as default filter maskBits',
(game) async { (game) async {
final ball = Ball(); final ball = Ball();
await game.ready();
await game.ensureAdd(ball); await game.ensureAdd(ball);
await ball.mounted; await game.ready();
final fixture = ball.body.fixtures[0]; final fixture = ball.body.fixtures[0];
expect(fixture.filterData.maskBits, equals(Layer.board.maskBits)); expect(fixture.filterData.maskBits, equals(Layer.board.maskBits));
@ -150,5 +171,60 @@ void main() {
}, },
); );
}); });
group('stop', () {
group("can't be moved", () {
flameTester.test('by its weight', (game) async {
final ball = Ball();
await game.ensureAdd(ball);
ball.stop();
game.update(1);
expect(ball.body.position, equals(ball.initialPosition));
});
});
flameTester.test('by applying velocity', (game) async {
final ball = Ball();
await game.ensureAdd(ball);
ball.stop();
ball.body.linearVelocity.setValues(10, 10);
game.update(1);
expect(ball.body.position, equals(ball.initialPosition));
});
});
group('resume', () {
group('can move', () {
flameTester.test(
'by its weight when previously stopped',
(game) async {
final ball = Ball();
await game.ensureAdd(ball);
ball.stop();
ball.resume();
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
},
);
flameTester.test(
'by applying velocity when previously stopped',
(game) async {
final ball = Ball();
await game.ensureAdd(ball);
ball.stop();
ball.resume();
ball.body.gravityScale = 0;
ball.body.linearVelocity.setValues(10, 10);
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
},
);
});
});
}); });
} }

@ -15,7 +15,7 @@ void main() {
flameTester.test( flameTester.test(
'loads correctly', 'loads correctly',
(game) async { (game) async {
final board = Board(size: Vector2.all(500)); final board = Board();
await game.ready(); await game.ready();
await game.ensureAdd(board); await game.ensureAdd(board);
@ -27,7 +27,7 @@ void main() {
flameTester.test( flameTester.test(
'has one left flipper', 'has one left flipper',
(game) async { (game) async {
final board = Board(size: Vector2.all(500)); final board = Board();
await game.ready(); await game.ready();
await game.ensureAdd(board); await game.ensureAdd(board);
@ -41,7 +41,7 @@ void main() {
flameTester.test( flameTester.test(
'has one right flipper', 'has one right flipper',
(game) async { (game) async {
final board = Board(size: Vector2.all(500)); final board = Board();
await game.ready(); await game.ready();
await game.ensureAdd(board); await game.ensureAdd(board);
@ -55,7 +55,7 @@ void main() {
flameTester.test( flameTester.test(
'has two Baseboards', 'has two Baseboards',
(game) async { (game) async {
final board = Board(size: Vector2.all(500)); final board = Board();
await game.ready(); await game.ready();
await game.ensureAdd(board); await game.ensureAdd(board);
@ -65,14 +65,14 @@ void main() {
); );
flameTester.test( flameTester.test(
'has two SlingShots', 'has two Kickers',
(game) async { (game) async {
final board = Board(size: Vector2.all(500)); final board = Board();
await game.ready(); await game.ready();
await game.ensureAdd(board); await game.ensureAdd(board);
final slingShots = board.findNestedChildren<SlingShot>(); final kickers = board.findNestedChildren<Kicker>();
expect(slingShots.length, equals(2)); expect(kickers.length, equals(2));
}, },
); );
@ -80,7 +80,7 @@ void main() {
'has three RoundBumpers', 'has three RoundBumpers',
(game) async { (game) async {
// TODO(alestiago): change to [NestBumpers] once provided. // TODO(alestiago): change to [NestBumpers] once provided.
final board = Board(size: Vector2.all(500)); final board = Board();
await game.ready(); await game.ready();
await game.ensureAdd(board); await game.ensureAdd(board);

@ -282,7 +282,7 @@ void main() {
final flipperAnchor = FlipperAnchor(flipper: flipper); final flipperAnchor = FlipperAnchor(flipper: flipper);
await game.ensureAdd(flipperAnchor); await game.ensureAdd(flipperAnchor);
expect(flipperAnchor.body.position.x, equals(-Flipper.width / 2)); expect(flipperAnchor.body.position.x, equals(-Flipper.size.x / 2));
}, },
); );
@ -297,7 +297,7 @@ void main() {
final flipperAnchor = FlipperAnchor(flipper: flipper); final flipperAnchor = FlipperAnchor(flipper: flipper);
await game.ensureAdd(flipperAnchor); await game.ensureAdd(flipperAnchor);
expect(flipperAnchor.body.position.x, equals(Flipper.width / 2)); expect(flipperAnchor.body.position.x, equals(Flipper.size.x / 2));
}, },
); );
}); });

@ -0,0 +1,69 @@
// ignore_for_file: cascade_invocations
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart';
void main() {
group('Kicker', () {
// TODO(alestiago): Include golden tests for left and right.
final flameTester = FlameTester(Forge2DGame.new);
flameTester.test(
'loads correctly',
(game) async {
final kicker = Kicker(
side: BoardSide.left,
);
await game.ensureAdd(kicker);
expect(game.contains(kicker), isTrue);
},
);
flameTester.test(
'body is static',
(game) async {
final kicker = Kicker(
side: BoardSide.left,
);
await game.ensureAdd(kicker);
expect(kicker.body.bodyType, equals(BodyType.static));
},
);
flameTester.test(
'has restitution',
(game) async {
final kicker = Kicker(
side: BoardSide.left,
);
await game.ensureAdd(kicker);
final totalRestitution = kicker.body.fixtures.fold<double>(
0,
(total, fixture) => total + fixture.restitution,
);
expect(totalRestitution, greaterThan(0));
},
);
flameTester.test(
'has no friction',
(game) async {
final kicker = Kicker(
side: BoardSide.left,
);
await game.ensureAdd(kicker);
final totalFriction = kicker.body.fixtures.fold<double>(
0,
(total, fixture) => total + fixture.friction,
);
expect(totalFriction, equals(0));
},
);
});
}

@ -1,155 +0,0 @@
// ignore_for_file: cascade_invocations
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart';
void main() {
group('SlingShot', () {
final flameTester = FlameTester(Forge2DGame.new);
flameTester.test(
'loads correctly',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
expect(game.contains(slingShot), isTrue);
},
);
group('body', () {
flameTester.test(
'is static',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
expect(slingShot.body.bodyType, equals(BodyType.static));
},
);
});
group('first fixture', () {
flameTester.test(
'exists',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
expect(slingShot.body.fixtures[0], isA<Fixture>());
},
);
flameTester.test(
'shape is triangular',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
final fixture = slingShot.body.fixtures[0];
expect(fixture.shape.shapeType, equals(ShapeType.polygon));
expect((fixture.shape as PolygonShape).vertices.length, equals(3));
},
);
flameTester.test(
'triangular shapes are different '
'when side is left or right',
(game) async {
final leftSlingShot = SlingShot(
side: BoardSide.left,
);
final rightSlingShot = SlingShot(
side: BoardSide.right,
);
await game.ensureAdd(leftSlingShot);
await game.ensureAdd(rightSlingShot);
final rightShape =
rightSlingShot.body.fixtures[0].shape as PolygonShape;
final leftShape =
leftSlingShot.body.fixtures[0].shape as PolygonShape;
expect(rightShape.vertices, isNot(equals(leftShape.vertices)));
},
);
flameTester.test(
'has no friction',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
final fixture = slingShot.body.fixtures[0];
expect(fixture.friction, equals(0));
},
);
});
group('second fixture', () {
flameTester.test(
'exists',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
expect(slingShot.body.fixtures[1], isA<Fixture>());
},
);
flameTester.test(
'shape is edge',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
final fixture = slingShot.body.fixtures[1];
expect(fixture.shape.shapeType, equals(ShapeType.edge));
},
);
flameTester.test(
'has restitution',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
final fixture = slingShot.body.fixtures[1];
expect(fixture.restitution, greaterThan(0));
},
);
flameTester.test(
'has no friction',
(game) async {
final slingShot = SlingShot(
side: BoardSide.left,
);
await game.ensureAdd(slingShot);
final fixture = slingShot.body.fixtures[1];
expect(fixture.friction, equals(0));
},
);
});
});
}

@ -54,17 +54,6 @@ void main() {
verify(game.reorderChildren).called(1); verify(game.reorderChildren).called(1);
}); });
test('changes the filter data from the ball fixtures', () {
SpaceshipEntranceBallContactCallback().begin(
entrance,
ball,
MockContact(),
);
verify(() => filterData.maskBits = 0x0002).called(1);
verify(() => filterData.categoryBits = 0x0002).called(1);
});
}); });
group('SpaceshipHoleBallContactCallback', () { group('SpaceshipHoleBallContactCallback', () {
@ -87,17 +76,6 @@ void main() {
verify(game.reorderChildren).called(1); verify(game.reorderChildren).called(1);
}); });
test('changes the filter data from the ball fixtures', () {
SpaceshipHoleBallContactCallback().begin(
hole,
ball,
MockContact(),
);
verify(() => filterData.categoryBits = 0xFFFF).called(1);
verify(() => filterData.maskBits = 0x0001).called(1);
});
}); });
}); });
} }

@ -25,9 +25,7 @@ void main() {
final walls = game.children.where( final walls = game.children.where(
(component) => component is Wall && component is! BottomWall, (component) => component is Wall && component is! BottomWall,
); );
// TODO(allisonryan0002): expect 3 when launch track is added and expect(walls.length, 3);
// temporary wall is removed.
expect(walls.length, 4);
}, },
); );

@ -18,6 +18,9 @@ class MockBall extends Mock implements Ball {}
class MockContact extends Mock implements Contact {} class MockContact extends Mock implements Contact {}
class MockContactCallback extends Mock
implements ContactCallback<Object, Object> {}
class MockRampOpening extends Mock implements RampOpening {} class MockRampOpening extends Mock implements RampOpening {}
class MockRampOpeningBallContactCallback extends Mock class MockRampOpeningBallContactCallback extends Mock

@ -0,0 +1,166 @@
// ignore_for_file: prefer_const_constructors
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
class MockLeaderboardRepository extends Mock implements LeaderboardRepository {}
void main() {
group('LeaderboardBloc', () {
late LeaderboardRepository leaderboardRepository;
setUp(() {
leaderboardRepository = MockLeaderboardRepository();
});
test('initial state has state loading no ranking and empty leaderboard',
() {
final bloc = LeaderboardBloc(leaderboardRepository);
expect(bloc.state.status, equals(LeaderboardStatus.loading));
expect(bloc.state.ranking.ranking, equals(0));
expect(bloc.state.ranking.outOf, equals(0));
expect(bloc.state.leaderboard.isEmpty, isTrue);
});
group('Top10Fetched', () {
const top10Scores = [
2500,
2200,
2200,
2000,
1800,
1400,
1300,
1000,
600,
300,
100,
];
final top10Leaderboard = top10Scores
.map(
(score) => LeaderboardEntry(
playerInitials: 'user$score',
score: score,
character: CharacterType.dash,
),
)
.toList();
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, success] statuses '
'when fetchTop10Leaderboard succeeds',
setUp: () {
when(() => leaderboardRepository.fetchTop10Leaderboard()).thenAnswer(
(_) async => top10Leaderboard,
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(Top10Fetched()),
expect: () => [
LeaderboardState.initial(),
isA<LeaderboardState>()
..having(
(element) => element.status,
'status',
equals(LeaderboardStatus.success),
)
..having(
(element) => element.leaderboard.length,
'leaderboard',
equals(top10Leaderboard.length),
)
],
verify: (_) =>
verify(() => leaderboardRepository.fetchTop10Leaderboard())
.called(1),
);
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, error] statuses '
'when fetchTop10Leaderboard fails',
setUp: () {
when(() => leaderboardRepository.fetchTop10Leaderboard()).thenThrow(
Exception(),
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(Top10Fetched()),
expect: () => <LeaderboardState>[
LeaderboardState.initial(),
LeaderboardState.initial().copyWith(status: LeaderboardStatus.error),
],
verify: (_) =>
verify(() => leaderboardRepository.fetchTop10Leaderboard())
.called(1),
errors: () => [isA<Exception>()],
);
});
group('LeaderboardEntryAdded', () {
final leaderboardEntry = LeaderboardEntry(
playerInitials: 'ABC',
score: 1500,
character: CharacterType.dash,
);
final ranking = LeaderboardRanking(ranking: 3, outOf: 4);
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, success] statuses '
'when addLeaderboardEntry succeeds',
setUp: () {
when(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).thenAnswer(
(_) async => ranking,
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(LeaderboardEntryAdded(entry: leaderboardEntry)),
expect: () => [
LeaderboardState.initial(),
isA<LeaderboardState>()
..having(
(element) => element.status,
'status',
equals(LeaderboardStatus.success),
)
..having(
(element) => element.ranking,
'ranking',
equals(ranking),
)
],
verify: (_) => verify(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).called(1),
);
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, error] statuses '
'when addLeaderboardEntry fails',
setUp: () {
when(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).thenThrow(
Exception(),
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(LeaderboardEntryAdded(entry: leaderboardEntry)),
expect: () => <LeaderboardState>[
LeaderboardState.initial(),
LeaderboardState.initial().copyWith(status: LeaderboardStatus.error),
],
verify: (_) => verify(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).called(1),
errors: () => [isA<Exception>()],
);
});
});
}

@ -0,0 +1,41 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
void main() {
group('GameEvent', () {
group('Top10Fetched', () {
test('can be instantiated', () {
expect(const Top10Fetched(), isNotNull);
});
test('supports value equality', () {
expect(
Top10Fetched(),
equals(const Top10Fetched()),
);
});
});
group('LeaderboardEntryAdded', () {
const leaderboardEntry = LeaderboardEntry(
playerInitials: 'ABC',
score: 1500,
character: CharacterType.dash,
);
test('can be instantiated', () {
expect(const LeaderboardEntryAdded(entry: leaderboardEntry), isNotNull);
});
test('supports value equality', () {
expect(
LeaderboardEntryAdded(entry: leaderboardEntry),
equals(const LeaderboardEntryAdded(entry: leaderboardEntry)),
);
});
});
});
}

@ -0,0 +1,70 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
void main() {
group('LeaderboardState', () {
test('supports value equality', () {
expect(
LeaderboardState.initial(),
equals(
LeaderboardState.initial(),
),
);
});
group('constructor', () {
test('can be instantiated', () {
expect(
LeaderboardState.initial(),
isNotNull,
);
});
});
group('copyWith', () {
const leaderboardEntry = LeaderboardEntry(
playerInitials: 'ABC',
score: 1500,
character: CharacterType.dash,
);
test(
'copies correctly '
'when no argument specified',
() {
const leaderboardState = LeaderboardState.initial();
expect(
leaderboardState.copyWith(),
equals(leaderboardState),
);
},
);
test(
'copies correctly '
'when all arguments specified',
() {
const leaderboardState = LeaderboardState.initial();
final otherLeaderboardState = LeaderboardState(
status: LeaderboardStatus.success,
ranking: LeaderboardRanking(ranking: 0, outOf: 0),
leaderboard: const [leaderboardEntry],
);
expect(leaderboardState, isNot(equals(otherLeaderboardState)));
expect(
leaderboardState.copyWith(
status: otherLeaderboardState.status,
ranking: otherLeaderboardState.ranking,
leaderboard: otherLeaderboardState.leaderboard,
),
equals(otherLeaderboardState),
);
},
);
});
});
}
Loading…
Cancel
Save