Merge branch 'main' into feat/add-user-at-end-game

pull/98/head
RuiAlonso 4 years ago
commit 4e43d25c6d

@ -8,3 +8,4 @@ jobs:
with: with:
flutter_channel: stable flutter_channel: stable
flutter_version: 2.10.0 flutter_version: 2.10.0
coverage_excludes: "lib/gen/*.dart"

@ -0,0 +1,19 @@
name: pinball_components
on:
push:
paths:
- "packages/pinball_components/**"
- ".github/workflows/pinball_components.yaml"
pull_request:
paths:
- "packages/pinball_components/**"
- ".github/workflows/pinball_components.yaml"
jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
working_directory: packages/pinball_components
coverage_excludes: "lib/src/generated/*.dart"

@ -1 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml include: package:very_good_analysis/analysis_options.2.4.0.yaml
analyzer:
exclude:
- lib/**/*.gen.dart

@ -12,6 +12,7 @@ class GameBloc extends Bloc<GameEvent, GameState> {
on<BallLost>(_onBallLost); on<BallLost>(_onBallLost);
on<Scored>(_onScored); on<Scored>(_onScored);
on<BonusLetterActivated>(_onBonusLetterActivated); on<BonusLetterActivated>(_onBonusLetterActivated);
on<DashNestActivated>(_onDashNestActivated);
} }
static const bonusWord = 'GOOGLE'; static const bonusWord = 'GOOGLE';
@ -52,4 +53,28 @@ class GameBloc extends Bloc<GameEvent, GameState> {
); );
} }
} }
void _onDashNestActivated(DashNestActivated event, Emitter emit) {
const nestsRequiredForBonus = 3;
final newNests = {
...state.activatedDashNests,
event.nestId,
};
if (newNests.length == nestsRequiredForBonus) {
emit(
state.copyWith(
activatedDashNests: {},
bonusHistory: [
...state.bonusHistory,
GameBonus.dashNest,
],
),
);
} else {
emit(
state.copyWith(activatedDashNests: newNests),
);
}
}
} }

@ -45,3 +45,12 @@ class BonusLetterActivated extends GameEvent {
@override @override
List<Object?> get props => [letterIndex]; List<Object?> get props => [letterIndex];
} }
class DashNestActivated extends GameEvent {
const DashNestActivated(this.nestId);
final String nestId;
@override
List<Object?> get props => [nestId];
}

@ -7,6 +7,10 @@ enum GameBonus {
/// Bonus achieved when the user activate all of the bonus /// Bonus achieved when the user activate all of the bonus
/// letters on the board, forming the bonus word /// letters on the board, forming the bonus word
word, word,
/// Bonus achieved when the user activates all of the Dash
/// nests on the board, adding a new ball to the board.
dashNest,
} }
/// {@template game_state} /// {@template game_state}
@ -19,6 +23,7 @@ class GameState extends Equatable {
required this.balls, required this.balls,
required this.activatedBonusLetters, required this.activatedBonusLetters,
required this.bonusHistory, required this.bonusHistory,
required this.activatedDashNests,
}) : assert(score >= 0, "Score can't be negative"), }) : assert(score >= 0, "Score can't be negative"),
assert(balls >= 0, "Number of balls can't be negative"); assert(balls >= 0, "Number of balls can't be negative");
@ -26,6 +31,7 @@ class GameState extends Equatable {
: score = 0, : score = 0,
balls = 3, balls = 3,
activatedBonusLetters = const [], activatedBonusLetters = const [],
activatedDashNests = const {},
bonusHistory = const []; bonusHistory = const [];
/// The current score of the game. /// The current score of the game.
@ -39,6 +45,9 @@ class GameState extends Equatable {
/// Active bonus letters. /// Active bonus letters.
final List<int> activatedBonusLetters; final List<int> activatedBonusLetters;
/// Active dash nests.
final Set<String> activatedDashNests;
/// Holds the history of all the [GameBonus]es earned by the player during a /// Holds the history of all the [GameBonus]es earned by the player during a
/// PinballGame. /// PinballGame.
final List<GameBonus> bonusHistory; final List<GameBonus> bonusHistory;
@ -57,6 +66,7 @@ class GameState extends Equatable {
int? score, int? score,
int? balls, int? balls,
List<int>? activatedBonusLetters, List<int>? activatedBonusLetters,
Set<String>? activatedDashNests,
List<GameBonus>? bonusHistory, List<GameBonus>? bonusHistory,
}) { }) {
assert( assert(
@ -69,6 +79,7 @@ class GameState extends Equatable {
balls: balls ?? this.balls, balls: balls ?? this.balls,
activatedBonusLetters: activatedBonusLetters:
activatedBonusLetters ?? this.activatedBonusLetters, activatedBonusLetters ?? this.activatedBonusLetters,
activatedDashNests: activatedDashNests ?? this.activatedDashNests,
bonusHistory: bonusHistory ?? this.bonusHistory, bonusHistory: bonusHistory ?? this.bonusHistory,
); );
} }
@ -78,6 +89,7 @@ class GameState extends Equatable {
score, score,
balls, balls,
activatedBonusLetters, activatedBonusLetters,
activatedDashNests,
bonusHistory, bonusHistory,
]; ];
} }

@ -1,6 +1,7 @@
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/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
/// {@template ball} /// {@template ball}
/// A solid, [BodyType.dynamic] sphere that rolls and bounces along the /// A solid, [BodyType.dynamic] sphere that rolls and bounces along the
@ -20,15 +21,10 @@ class Ball extends BodyComponent<PinballGame> with InitialPosition, Layered {
/// The size of the [Ball] /// The size of the [Ball]
final Vector2 size = Vector2.all(2); final Vector2 size = Vector2.all(2);
/// Asset location of the sprite that renders with the [Ball].
///
/// Sprite is preloaded by [PinballGameAssetsX].
static const spritePath = 'components/ball.png';
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
final sprite = await gameRef.loadSprite(spritePath); final sprite = await gameRef.loadSprite(Assets.images.components.ball.path);
final tint = gameRef.theme.characterTheme.ballColor.withOpacity(0.5); final tint = gameRef.theme.characterTheme.ballColor.withOpacity(0.5);
await add( await add(
SpriteComponent( SpriteComponent(

@ -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.center.dx + 20,
_size.y / 4.25, PinballGame.boardBounds.center.dy + 48,
), ),
); );
@ -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,

@ -36,23 +36,31 @@ class BonusWord extends Component with BlocComponent<GameBloc, GameState> {
for (var i = 0; i < letters.length; i++) { for (var i = 0; i < letters.length; i++) {
final letter = letters[i]; final letter = letters[i];
letter.add( letter
..isEnabled = false
..add(
SequenceEffect( SequenceEffect(
[ [
ColorEffect( ColorEffect(
i.isOdd ? BonusLetter._activeColor : BonusLetter._disableColor, i.isOdd
? BonusLetter._activeColor
: BonusLetter._disableColor,
const Offset(0, 1), const Offset(0, 1),
EffectController(duration: 0.25), EffectController(duration: 0.25),
), ),
ColorEffect( ColorEffect(
i.isOdd ? BonusLetter._disableColor : BonusLetter._activeColor, i.isOdd
? BonusLetter._disableColor
: BonusLetter._activeColor,
const Offset(0, 1), const Offset(0, 1),
EffectController(duration: 0.25), EffectController(duration: 0.25),
), ),
], ],
repeatCount: 4, repeatCount: 4,
)..onFinishCallback = () { )..onFinishCallback = () {
letter.add( letter
..isEnabled = true
..add(
ColorEffect( ColorEffect(
BonusLetter._disableColor, BonusLetter._disableColor,
const Offset(0, 1), const Offset(0, 1),
@ -107,6 +115,13 @@ class BonusLetter extends BodyComponent<PinballGame>
final String _letter; final String _letter;
final int _index; final int _index;
/// Indicates if a [BonusLetter] can be activated on [Ball] contact.
///
/// It is disabled whilst animating and enabled again once the animation
/// completes. The animation is triggered when [GameBonus.word] is
/// awarded.
bool isEnabled = true;
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
@ -172,6 +187,8 @@ class BonusLetterBallContactCallback
extends ContactCallback<Ball, BonusLetter> { extends ContactCallback<Ball, BonusLetter> {
@override @override
void begin(Ball ball, BonusLetter bonusLetter, Contact contact) { void begin(Ball ball, BonusLetter bonusLetter, Contact contact) {
if (bonusLetter.isEnabled) {
bonusLetter.activate(); bonusLetter.activate();
} }
}
} }

@ -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,11 +2,11 @@ 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';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
/// {@template flipper} /// {@template flipper}
/// A bat, typically found in pairs at the bottom of the board. /// A bat, typically found in pairs at the bottom of the board.
@ -53,16 +53,8 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
} }
} }
/// Asset location of the sprite that renders with the [Flipper]. /// The size of the [Flipper].
/// static final size = Vector2(12, 2.8);
/// Sprite is preloaded by [PinballGameAssetsX].
static const spritePath = 'components/flipper.png';
/// 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 speed required to move the [Flipper] to its highest position.
/// ///
@ -94,10 +86,12 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
/// Loads the sprite that renders with the [Flipper]. /// Loads the sprite that renders with the [Flipper].
Future<void> _loadSprite() async { Future<void> _loadSprite() async {
final sprite = await gameRef.loadSprite(spritePath); final sprite = await gameRef.loadSprite(
Assets.images.components.flipper.path,
);
final spriteComponent = SpriteComponent( final spriteComponent = SpriteComponent(
sprite: sprite, sprite: sprite,
size: Vector2(width, height), size: size,
anchor: Anchor.center, anchor: Anchor.center,
); );
@ -134,21 +128,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);
@ -227,8 +221,8 @@ 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,
); );
} }

@ -30,18 +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: 62, width: 5,
radius: 200, radius: 18,
angle: math.pi, angle: math.pi,
rotation: math.pi,
)..layer = layer;
final leftOpening = _JetpackRampOpening(
outsideLayer: Layer.spaceship,
rotation: math.pi,
) )
..initialPosition = position ..initialPosition = position + Vector2(-2.5, -20.2)
..layer = layer;
final leftOpening = _JetpackRampOpening(outsideLayer: Layer.spaceship)
..initialPosition = position + Vector2(-27.6, 25.3)
..layer = Layer.jetpack; ..layer = Layer.jetpack;
final rightOpening = _JetpackRampOpening() final rightOpening = _JetpackRampOpening(
..initialPosition = position + Vector2(-10.6, 25.3) rotation: math.pi,
)
..initialPosition = position + Vector2(12.9, -20.2)
..layer = Layer.opening; ..layer = Layer.opening;
await addAll([ await addAll([
@ -60,20 +65,26 @@ class _JetpackRampOpening extends RampOpening {
/// {@macro jetpack_ramp_opening} /// {@macro jetpack_ramp_opening}
_JetpackRampOpening({ _JetpackRampOpening({
Layer? outsideLayer, Layer? outsideLayer,
}) : super( required double rotation,
}) : _rotation = rotation,
super(
pathwayLayer: Layer.jetpack, pathwayLayer: Layer.jetpack,
outsideLayer: outsideLayer, outsideLayer: outsideLayer,
orientation: RampOrientation.down, orientation: RampOrientation.down,
); );
// TODO(ruialonso): Avoid magic number 2, should be proportional to final double _rotation;
// TODO(ruialonso): Avoid magic number 3, should be propotional to
// [JetpackRamp]. // [JetpackRamp].
static const _size = 2; static final Vector2 _size = Vector2(3, .1);
@override @override
Shape get shape => PolygonShape() Shape get shape => PolygonShape()
..setAsEdge( ..setAsBox(
Vector2(initialPosition.x - _size, initialPosition.y), _size.x,
Vector2(initialPosition.x + _size, initialPosition.y), _size.y,
initialPosition,
_rotation,
); );
} }

@ -6,15 +6,15 @@ import 'package:flutter/material.dart';
import 'package:geometry/geometry.dart' as geometry show centroid; import 'package:geometry/geometry.dart' as geometry show centroid;
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
/// {@template sling_shot} /// {@template kicker}
/// Triangular [BodyType.static] body that propels the [Ball] towards the /// Triangular [BodyType.static] body that propels the [Ball] towards the
/// opposite side. /// opposite side.
/// ///
/// [SlingShot]s are usually positioned above each [Flipper]. /// [Kicker]s are usually positioned above each [Flipper].
/// {@endtemplate sling_shot} /// {@endtemplate kicker}
class SlingShot extends BodyComponent with InitialPosition { class Kicker extends BodyComponent with InitialPosition {
/// {@macro sling_shot} /// {@macro kicker}
SlingShot({ Kicker({
required BoardSide side, required BoardSide side,
}) : _side = side { }) : _side = side {
// TODO(alestiago): Use sprite instead of color when provided. // TODO(alestiago): Use sprite instead of color when provided.
@ -23,14 +23,14 @@ class SlingShot extends BodyComponent with InitialPosition {
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
} }
/// Whether the [SlingShot] is on the left or right side of the board. /// Whether the [Kicker] is on the left or right side of the board.
/// ///
/// A [SlingShot] with [BoardSide.left] propels the [Ball] to the right, /// A [Kicker] with [BoardSide.left] propels the [Ball] to the right,
/// whereas a [SlingShot] with [BoardSide.right] propels the [Ball] to the /// whereas a [Kicker] with [BoardSide.right] propels the [Ball] to the
/// left. /// left.
final BoardSide _side; final BoardSide _side;
/// The size of the [SlingShot] body. /// The size of the [Kicker] body.
// TODO(alestiago): Use size from PositionedBodyComponent instead, // TODO(alestiago): Use size from PositionedBodyComponent instead,
// once a sprite is given. // once a sprite is given.
static final Vector2 size = Vector2(4, 10); static final Vector2 size = Vector2(4, 10);
@ -78,7 +78,7 @@ class SlingShot extends BodyComponent with InitialPosition {
final bottomLineFixtureDef = FixtureDef(bottomEdge)..friction = 0; final bottomLineFixtureDef = FixtureDef(bottomEdge)..friction = 0;
fixturesDefs.add(bottomLineFixtureDef); fixturesDefs.add(bottomLineFixtureDef);
final kickerEdge = EdgeShape() final bouncyEdge = EdgeShape()
..set( ..set(
upperCircle.position + upperCircle.position +
Vector2( Vector2(
@ -92,11 +92,11 @@ class SlingShot extends BodyComponent with InitialPosition {
), ),
); );
final kickerFixtureDef = FixtureDef(kickerEdge) final bouncyFixtureDef = FixtureDef(bouncyEdge)
// TODO(alestiago): Play with restitution value once game is bundled. // TODO(alestiago): Play with restitution value once game is bundled.
..restitution = 10.0 ..restitution = 10.0
..friction = 0; ..friction = 0;
fixturesDefs.add(kickerFixtureDef); fixturesDefs.add(bouncyFixtureDef);
// TODO(alestiago): Evaluate if there is value on centering the fixtures. // TODO(alestiago): Evaluate if there is value on centering the fixtures.
final centroid = geometry.centroid( final centroid = geometry.centroid(

@ -26,28 +26,33 @@ class LauncherRamp extends Component with HasGameRef<PinballGame> {
RampOpeningBallContactCallback<_LauncherRampOpening>(), RampOpeningBallContactCallback<_LauncherRampOpening>(),
); );
final launcherRampRotation =
-math.atan(18.6 / PinballGame.boardBounds.height);
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: position + Vector2(-1.2, 10),
end: Vector2(0, 700), end: position + Vector2(-1.2, 117),
width: 80, width: 5,
rotation: launcherRampRotation,
) )
..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(-2.8, 87.2),
radius: 300, radius: 16.3,
angle: 10 * math.pi / 9, angle: math.pi / 2,
width: 80, width: 5,
) rotation: 3 * math.pi / 2,
..initialPosition = position + Vector2(-28.8, -6) )..layer = layer;
..layer = layer;
final leftOpening = _LauncherRampOpening(rotation: 13 * math.pi / 180) final leftOpening = _LauncherRampOpening(rotation: math.pi / 2)
..initialPosition = position + Vector2(-72.5, 12) ..initialPosition = position + Vector2(-11.8, 66.3)
..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(-4.9, 59.4)
..layer = Layer.opening; ..layer = Layer.opening;
await addAll([ await addAll([

@ -146,14 +146,12 @@ class Pathway extends BodyComponent with InitialPosition, Layered {
final List<List<Vector2>> _paths; final List<List<Vector2>> _paths;
List<FixtureDef> _createFixtureDefs() { /// Constructs different [ChainShape]s to form the [Pathway] shape.
List<FixtureDef> createFixtureDefs() {
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));
} }
@ -164,7 +162,7 @@ class Pathway extends BodyComponent with InitialPosition, Layered {
Body createBody() { Body createBody() {
final bodyDef = BodyDef()..position = initialPosition; final bodyDef = BodyDef()..position = initialPosition;
final body = world.createBody(bodyDef); final body = world.createBody(bodyDef);
_createFixtureDefs().forEach(body.createFixture); createFixtureDefs().forEach(body.createFixture);
return body; return body;
} }

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

@ -9,6 +9,12 @@ import 'package:pinball/game/game.dart';
mixin ScorePoints on BodyComponent { mixin ScorePoints on BodyComponent {
/// {@macro score_points} /// {@macro score_points}
int get points; int get points;
@override
Future<void> onLoad() async {
await super.onLoad();
body.userData = this;
}
} }
/// Adds points to the score when a [Ball] collides with a [BodyComponent] that /// Adds points to the score when a [Ball] collides with a [BodyComponent] that

@ -7,6 +7,7 @@ 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/flame/blueprint.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
/// A [Blueprint] which creates the spaceship feature. /// A [Blueprint] which creates the spaceship feature.
class Spaceship extends Forge2DBlueprint { class Spaceship extends Forge2DBlueprint {
@ -15,7 +16,10 @@ class Spaceship extends Forge2DBlueprint {
@override @override
void build() { void build() {
final position = Vector2(30, -50); final position = Vector2(
PinballGame.boardBounds.left + radius + 15,
PinballGame.boardBounds.center.dy + 30,
);
addAllContactCallback([ addAllContactCallback([
SpaceshipHoleBallContactCallback(), SpaceshipHoleBallContactCallback(),
@ -43,18 +47,12 @@ class SpaceshipSaucer extends BodyComponent with InitialPosition, Layered {
layer = Layer.spaceship; layer = Layer.spaceship;
} }
/// Path for the base sprite
static const saucerSpritePath = 'components/spaceship/saucer.png';
/// Path for the upper wall sprite
static const upperWallPath = 'components/spaceship/upper.png';
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
final sprites = await Future.wait([ final sprites = await Future.wait([
gameRef.loadSprite(saucerSpritePath), gameRef.loadSprite(Assets.images.components.spaceship.saucer.path),
gameRef.loadSprite(upperWallPath), gameRef.loadSprite(Assets.images.components.spaceship.upper.path),
]); ]);
await add( await add(
@ -101,14 +99,13 @@ class SpaceshipBridgeTop extends BodyComponent with InitialPosition {
/// {@macro spaceship_bridge_top} /// {@macro spaceship_bridge_top}
SpaceshipBridgeTop() : super(priority: 6); SpaceshipBridgeTop() : super(priority: 6);
/// Path to the top of this sprite
static const spritePath = 'components/spaceship/android-top.png';
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
final sprite = await gameRef.loadSprite(spritePath); final sprite = await gameRef.loadSprite(
Assets.images.components.spaceship.androidTop.path,
);
await add( await add(
SpriteComponent( SpriteComponent(
sprite: sprite, sprite: sprite,
@ -139,16 +136,15 @@ class SpaceshipBridge extends BodyComponent with InitialPosition, Layered {
layer = Layer.spaceship; layer = Layer.spaceship;
} }
/// Path to the spaceship bridge
static const spritePath = 'components/spaceship/android-bottom.png';
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
renderBody = false; renderBody = false;
final sprite = await gameRef.images.load(spritePath); final sprite = await gameRef.images.load(
Assets.images.components.spaceship.androidBottom.path,
);
await add( await add(
SpriteAnimationComponent.fromFrameData( SpriteAnimationComponent.fromFrameData(
sprite, sprite,
@ -250,14 +246,13 @@ class SpaceshipWall extends BodyComponent with InitialPosition, Layered {
layer = Layer.spaceship; layer = Layer.spaceship;
} }
/// Sprite path for the lower wall
static const lowerWallPath = 'components/spaceship/lower.png';
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
final sprite = await gameRef.loadSprite(lowerWallPath); final sprite = await gameRef.loadSprite(
Assets.images.components.spaceship.lower.path,
);
await add( await add(
SpriteComponent( SpriteComponent(

@ -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,18 @@ 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 =
final bottomRight = game.screenToWorld(game.camera.viewport.effectiveSize); PinballGame.boardBounds.topLeft.toVector2() + Vector2(18.6, 0);
final topRight = Vector2(bottomRight.x, topLeft.y); final bottomRight = PinballGame.boardBounds.bottomRight.toVector2();
final bottomLeft = Vector2(topLeft.x, bottomRight.y);
final topRight =
PinballGame.boardBounds.topRight.toVector2() - Vector2(18.6, 0);
final bottomLeft = PinballGame.boardBounds.bottomLeft.toVector2();
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 +64,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,16 +1,17 @@
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
/// Add methods to help loading and caching game assets. /// Add methods to help loading and caching game assets.
extension PinballGameAssetsX on PinballGame { extension PinballGameAssetsX on PinballGame {
/// Pre load the initial assets of the game. /// Pre load the initial assets of the game.
Future<void> preLoadAssets() async { Future<void> preLoadAssets() async {
await Future.wait([ await Future.wait([
images.load(Ball.spritePath), images.load(Assets.images.components.ball.path),
images.load(Flipper.spritePath), images.load(Assets.images.components.flipper.path),
images.load(SpaceshipBridge.spritePath), images.load(Assets.images.components.spaceship.androidTop.path),
images.load(SpaceshipBridgeTop.spritePath), images.load(Assets.images.components.spaceship.androidBottom.path),
images.load(SpaceshipWall.lowerWallPath), images.load(Assets.images.components.spaceship.lower.path),
images.load(SpaceshipSaucer.upperWallPath), images.load(Assets.images.components.spaceship.upper.path),
]); ]);
} }
} }

@ -1,6 +1,7 @@
// 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';
@ -10,12 +11,21 @@ import 'package:pinball_theme/pinball_theme.dart';
class PinballGame extends Forge2DGame class PinballGame extends Forge2DGame
with FlameBloc, HasKeyboardHandlerComponents { with FlameBloc, HasKeyboardHandlerComponents {
PinballGame({required this.theme}); PinballGame({required this.theme}) {
images.prefix = '';
}
final PinballTheme theme; final PinballTheme theme;
late final Plunger plunger; late final Plunger plunger;
static final boardSize = Vector2(101.6, 143.8);
static final boardBounds = Rect.fromCenter(
center: Offset.zero,
width: boardSize.x,
height: -boardSize.y,
);
@override @override
void onAttach() { void onAttach() {
super.onAttach(); super.onAttach();
@ -27,11 +37,16 @@ class PinballGame extends Forge2DGame
_addContactCallbacks(); _addContactCallbacks();
await _addGameBoundaries(); await _addGameBoundaries();
unawaited(_addBoard()); unawaited(add(Board()));
unawaited(_addPlunger()); unawaited(_addPlunger());
unawaited(_addBonusWord()); unawaited(_addBonusWord());
unawaited(_addPaths()); unawaited(_addPaths());
unawaited(addFromBlueprint(Spaceship())); unawaited(addFromBlueprint(Spaceship()));
// Fix camera on the center of the board.
camera
..followVector2(Vector2.zero())
..zoom = size.y / 16;
} }
void _addContactCallbacks() { void _addContactCallbacks() {
@ -41,44 +56,27 @@ class PinballGame extends Forge2DGame
} }
Future<void> _addGameBoundaries() async { Future<void> _addGameBoundaries() async {
await add(BottomWall(this)); await add(BottomWall());
createBoundaries(this).forEach(add); createBoundaries(this).forEach(add);
} }
Future<void> _addBoard() async {
final board = Board(
size: screenToWorld(
Vector2(
camera.viewport.effectiveSize.x,
camera.viewport.effectiveSize.y,
),
),
);
await add(board);
}
Future<void> _addPlunger() async { Future<void> _addPlunger() async {
plunger = Plunger( plunger = Plunger(compressionDistance: 2);
compressionDistance: camera.viewport.effectiveSize.y / 12,
); plunger.initialPosition = boardBounds.bottomRight.toVector2() +
plunger.initialPosition = screenToWorld(
Vector2( Vector2(
camera.viewport.effectiveSize.x / 2 + 450, -5,
camera.viewport.effectiveSize.y - plunger.compressionDistance, 10,
),
); );
await add(plunger); await add(plunger);
} }
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,
),
), ),
), ),
); );
@ -86,18 +84,22 @@ class PinballGame extends Forge2DGame
Future<void> _addPaths() async { Future<void> _addPaths() async {
final jetpackRamp = JetpackRamp( final jetpackRamp = JetpackRamp(
position: Vector2(42.6, -45), position: Vector2(
PinballGame.boardBounds.left + 40.5,
PinballGame.boardBounds.top - 31.5,
),
); );
final launcherRamp = LauncherRamp( final launcherRamp = LauncherRamp(
position: screenToWorld( position: Vector2(
Vector2( PinballGame.boardBounds.right - 30,
camera.viewport.effectiveSize.x / 2 + 400, PinballGame.boardBounds.bottom + 40,
camera.viewport.effectiveSize.y / 2 - 330,
),
), ),
); );
await addAll([jetpackRamp, launcherRamp]); await addAll([
jetpackRamp,
launcherRamp,
]);
} }
void spawnBall() { void spawnBall() {

@ -0,0 +1,96 @@
/// GENERATED CODE - DO NOT MODIFY BY HAND
/// *****************************************************
/// FlutterGen
/// *****************************************************
import 'package:flutter/widgets.dart';
class $AssetsImagesGen {
const $AssetsImagesGen();
$AssetsImagesComponentsGen get components =>
const $AssetsImagesComponentsGen();
}
class $AssetsImagesComponentsGen {
const $AssetsImagesComponentsGen();
AssetGenImage get ball =>
const AssetGenImage('assets/images/components/ball.png');
AssetGenImage get flipper =>
const AssetGenImage('assets/images/components/flipper.png');
AssetGenImage get sauce =>
const AssetGenImage('assets/images/components/sauce.png');
$AssetsImagesComponentsSpaceshipGen get spaceship =>
const $AssetsImagesComponentsSpaceshipGen();
}
class $AssetsImagesComponentsSpaceshipGen {
const $AssetsImagesComponentsSpaceshipGen();
AssetGenImage get androidBottom => const AssetGenImage(
'assets/images/components/spaceship/android-bottom.png');
AssetGenImage get androidTop =>
const AssetGenImage('assets/images/components/spaceship/android-top.png');
AssetGenImage get lower =>
const AssetGenImage('assets/images/components/spaceship/lower.png');
AssetGenImage get saucer =>
const AssetGenImage('assets/images/components/spaceship/saucer.png');
AssetGenImage get upper =>
const AssetGenImage('assets/images/components/spaceship/upper.png');
}
class Assets {
Assets._();
static const $AssetsImagesGen images = $AssetsImagesGen();
}
class AssetGenImage extends AssetImage {
const AssetGenImage(String assetName) : super(assetName);
Image image({
Key? key,
ImageFrameBuilder? frameBuilder,
ImageLoadingBuilder? loadingBuilder,
ImageErrorWidgetBuilder? errorBuilder,
String? semanticLabel,
bool excludeFromSemantics = false,
double? width,
double? height,
Color? color,
BlendMode? colorBlendMode,
BoxFit? fit,
AlignmentGeometry alignment = Alignment.center,
ImageRepeat repeat = ImageRepeat.noRepeat,
Rect? centerSlice,
bool matchTextDirection = false,
bool gaplessPlayback = false,
bool isAntiAlias = false,
FilterQuality filterQuality = FilterQuality.low,
}) {
return Image(
key: key,
image: this,
frameBuilder: frameBuilder,
loadingBuilder: loadingBuilder,
errorBuilder: errorBuilder,
semanticLabel: semanticLabel,
excludeFromSemantics: excludeFromSemantics,
width: width,
height: height,
color: color,
colorBlendMode: colorBlendMode,
fit: fit,
alignment: alignment,
repeat: repeat,
centerSlice: centerSlice,
matchTextDirection: matchTextDirection,
gaplessPlayback: gaplessPlayback,
isAntiAlias: isAntiAlias,
filterQuality: filterQuality,
);
}
String get path => assetName;
}

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

@ -0,0 +1,39 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VSCode related
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json

@ -0,0 +1,11 @@
# pinball_components
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
Package with the UI game components for the Pinball Game
[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 pinball_components;
export 'src/pinball_components.dart';

@ -0,0 +1,7 @@
/// {@template pinball_components}
/// Package with the UI game components for the Pinball Game
/// {@endtemplate}
class PinballComponents {
/// {@macro pinball_components}
const PinballComponents();
}

@ -0,0 +1,16 @@
name: pinball_components
description: Package with the UI game components for the Pinball Game
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=2.16.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
very_good_analysis: ^2.4.0

@ -0,0 +1,11 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
void main() {
group('PinballComponents', () {
test('can be instantiated', () {
expect(PinballComponents(), isNotNull);
});
});
}

@ -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
@ -42,3 +42,6 @@ flutter:
assets: assets:
- assets/images/components/ - assets/images/components/
- assets/images/components/spaceship/ - assets/images/components/spaceship/
flutter_gen:
line_length: 80

@ -25,18 +25,21 @@ void main() {
score: 0, score: 0,
balls: 2, balls: 2,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
const GameState( const GameState(
score: 0, score: 0,
balls: 1, balls: 1,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
const GameState( const GameState(
score: 0, score: 0,
balls: 0, balls: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
], ],
@ -56,12 +59,14 @@ void main() {
score: 2, score: 2,
balls: 3, balls: 3,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
const GameState( const GameState(
score: 5, score: 5,
balls: 3, balls: 3,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
], ],
@ -82,18 +87,21 @@ void main() {
score: 0, score: 0,
balls: 2, balls: 2,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
const GameState( const GameState(
score: 0, score: 0,
balls: 1, balls: 1,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
const GameState( const GameState(
score: 0, score: 0,
balls: 0, balls: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
], ],
@ -113,18 +121,21 @@ void main() {
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0], activatedBonusLetters: [0],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
GameState( GameState(
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0, 1], activatedBonusLetters: [0, 1],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
GameState( GameState(
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0, 1, 2], activatedBonusLetters: [0, 1, 2],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
], ],
@ -145,46 +156,87 @@ void main() {
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0], activatedBonusLetters: [0],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
GameState( GameState(
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0, 1], activatedBonusLetters: [0, 1],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
GameState( GameState(
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0, 1, 2], activatedBonusLetters: [0, 1, 2],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
GameState( GameState(
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0, 1, 2, 3], activatedBonusLetters: [0, 1, 2, 3],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
GameState( GameState(
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [0, 1, 2, 3, 4], activatedBonusLetters: [0, 1, 2, 3, 4],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
GameState( GameState(
score: 0, score: 0,
balls: 3, balls: 3,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [GameBonus.word], bonusHistory: [GameBonus.word],
), ),
GameState( GameState(
score: GameBloc.bonusWordScore, score: GameBloc.bonusWordScore,
balls: 3, balls: 3,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [GameBonus.word], bonusHistory: [GameBonus.word],
), ),
], ],
); );
}); });
group('DashNestActivated', () {
blocTest<GameBloc, GameState>(
'adds the bonus when all nests are activated',
build: GameBloc.new,
act: (bloc) => bloc
..add(const DashNestActivated('0'))
..add(const DashNestActivated('1'))
..add(const DashNestActivated('2')),
expect: () => const [
GameState(
score: 0,
balls: 3,
activatedBonusLetters: [],
activatedDashNests: {'0'},
bonusHistory: [],
),
GameState(
score: 0,
balls: 3,
activatedBonusLetters: [],
activatedDashNests: {'0', '1'},
bonusHistory: [],
),
GameState(
score: 0,
balls: 3,
activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [GameBonus.dashNest],
),
],
);
});
}); });
} }

@ -67,5 +67,22 @@ void main() {
}, },
); );
}); });
group('DashNestActivated', () {
test('can be instantiated', () {
expect(const DashNestActivated('0'), isNotNull);
});
test('supports value equality', () {
expect(
DashNestActivated('0'),
equals(DashNestActivated('0')),
);
expect(
DashNestActivated('0'),
isNot(equals(DashNestActivated('1'))),
);
});
});
}); });
} }

@ -11,6 +11,7 @@ void main() {
score: 0, score: 0,
balls: 0, balls: 0,
activatedBonusLetters: const [], activatedBonusLetters: const [],
activatedDashNests: const {},
bonusHistory: const [], bonusHistory: const [],
), ),
equals( equals(
@ -18,6 +19,7 @@ void main() {
score: 0, score: 0,
balls: 0, balls: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
), ),
@ -31,6 +33,7 @@ void main() {
score: 0, score: 0,
balls: 0, balls: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
isNotNull, isNotNull,
@ -47,6 +50,7 @@ void main() {
balls: -1, balls: -1,
score: 0, score: 0,
activatedBonusLetters: const [], activatedBonusLetters: const [],
activatedDashNests: const {},
bonusHistory: const [], bonusHistory: const [],
), ),
throwsAssertionError, throwsAssertionError,
@ -63,6 +67,7 @@ void main() {
balls: 0, balls: 0,
score: -1, score: -1,
activatedBonusLetters: const [], activatedBonusLetters: const [],
activatedDashNests: const {},
bonusHistory: const [], bonusHistory: const [],
), ),
throwsAssertionError, throwsAssertionError,
@ -78,6 +83,7 @@ void main() {
balls: 0, balls: 0,
score: 0, score: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect(gameState.isGameOver, isTrue); expect(gameState.isGameOver, isTrue);
@ -90,6 +96,7 @@ void main() {
balls: 1, balls: 1,
score: 0, score: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect(gameState.isGameOver, isFalse); expect(gameState.isGameOver, isFalse);
@ -105,6 +112,7 @@ void main() {
balls: 1, balls: 1,
score: 0, score: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect(gameState.isLastBall, isTrue); expect(gameState.isLastBall, isTrue);
@ -119,6 +127,7 @@ void main() {
balls: 2, balls: 2,
score: 0, score: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect(gameState.isLastBall, isFalse); expect(gameState.isLastBall, isFalse);
@ -134,6 +143,7 @@ void main() {
balls: 3, balls: 3,
score: 0, score: 0,
activatedBonusLetters: [1], activatedBonusLetters: [1],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect(gameState.isLetterActivated(1), isTrue); expect(gameState.isLetterActivated(1), isTrue);
@ -147,6 +157,7 @@ void main() {
balls: 3, balls: 3,
score: 0, score: 0,
activatedBonusLetters: [1], activatedBonusLetters: [1],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect(gameState.isLetterActivated(0), isFalse); expect(gameState.isLetterActivated(0), isFalse);
@ -163,6 +174,7 @@ void main() {
balls: 0, balls: 0,
score: 2, score: 2,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect( expect(
@ -180,6 +192,7 @@ void main() {
balls: 0, balls: 0,
score: 2, score: 2,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
expect( expect(
@ -197,12 +210,14 @@ void main() {
score: 2, score: 2,
balls: 0, balls: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
final otherGameState = GameState( final otherGameState = GameState(
score: gameState.score + 1, score: gameState.score + 1,
balls: gameState.balls + 1, balls: gameState.balls + 1,
activatedBonusLetters: const [0], activatedBonusLetters: const [0],
activatedDashNests: const {'1'},
bonusHistory: const [GameBonus.word], bonusHistory: const [GameBonus.word],
); );
expect(gameState, isNot(equals(otherGameState))); expect(gameState, isNot(equals(otherGameState)));
@ -212,6 +227,7 @@ void main() {
score: otherGameState.score, score: otherGameState.score,
balls: otherGameState.balls, balls: otherGameState.balls,
activatedBonusLetters: otherGameState.activatedBonusLetters, activatedBonusLetters: otherGameState.activatedBonusLetters,
activatedDashNests: otherGameState.activatedDashNests,
bonusHistory: otherGameState.bonusHistory, bonusHistory: otherGameState.bonusHistory,
), ),
equals(otherGameState), equals(otherGameState),

@ -156,6 +156,7 @@ void main() {
score: 10, score: 10,
balls: 1, balls: 1,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
), ),
); );

@ -1,6 +1,5 @@
// ignore_for_file: cascade_invocations // ignore_for_file: cascade_invocations
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart'; import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
@ -9,13 +8,13 @@ import '../../helpers/helpers.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(Forge2DGame.new); final flameTester = FlameTester(PinballGameTest.create);
group('Board', () { group('Board', () {
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,12 +26,12 @@ 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);
final leftFlippers = board.findNestedChildren<Flipper>( final leftFlippers = board.descendants().whereType<Flipper>().where(
condition: (flipper) => flipper.side.isLeft, (flipper) => flipper.side.isLeft,
); );
expect(leftFlippers.length, equals(1)); expect(leftFlippers.length, equals(1));
}, },
@ -41,12 +40,11 @@ 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);
final rightFlippers = board.descendants().whereType<Flipper>().where(
final rightFlippers = board.findNestedChildren<Flipper>( (flipper) => flipper.side.isRight,
condition: (flipper) => flipper.side.isRight,
); );
expect(rightFlippers.length, equals(1)); expect(rightFlippers.length, equals(1));
}, },
@ -55,24 +53,24 @@ 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);
final baseboards = board.findNestedChildren<Baseboard>(); final baseboards = board.descendants().whereType<Baseboard>();
expect(baseboards.length, equals(2)); expect(baseboards.length, equals(2));
}, },
); );
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.descendants().whereType<Kicker>();
expect(slingShots.length, equals(2)); expect(kickers.length, equals(2));
}, },
); );
@ -80,11 +78,11 @@ 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);
final roundBumpers = board.findNestedChildren<RoundBumper>(); final roundBumpers = board.descendants().whereType<RoundBumper>();
expect(roundBumpers.length, equals(3)); expect(roundBumpers.length, equals(3));
}, },
); );

@ -234,6 +234,7 @@ void main() {
score: 0, score: 0,
balls: 2, balls: 2,
activatedBonusLetters: [0], activatedBonusLetters: [0],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
whenListen( whenListen(
@ -259,6 +260,7 @@ void main() {
score: 0, score: 0,
balls: 2, balls: 2,
activatedBonusLetters: [0], activatedBonusLetters: [0],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
@ -283,6 +285,7 @@ void main() {
score: 0, score: 0,
balls: 2, balls: 2,
activatedBonusLetters: [0], activatedBonusLetters: [0],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );
@ -300,12 +303,26 @@ void main() {
test('calls ball.activate', () { test('calls ball.activate', () {
final ball = MockBall(); final ball = MockBall();
final bonusLetter = MockBonusLetter(); final bonusLetter = MockBonusLetter();
final contactCallback = BonusLetterBallContactCallback(); final contactCallback = BonusLetterBallContactCallback();
when(() => bonusLetter.isEnabled).thenReturn(true);
contactCallback.begin(ball, bonusLetter, MockContact()); contactCallback.begin(ball, bonusLetter, MockContact());
verify(bonusLetter.activate).called(1); verify(bonusLetter.activate).called(1);
}); });
test("doesn't call ball.activate when letter is disabled", () {
final ball = MockBall();
final bonusLetter = MockBonusLetter();
final contactCallback = BonusLetterBallContactCallback();
when(() => bonusLetter.isEnabled).thenReturn(false);
contactCallback.begin(ball, bonusLetter, MockContact());
verifyNever(bonusLetter.activate);
});
}); });
}); });
} }

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

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

@ -13,6 +13,7 @@ void main() {
score: 10, score: 10,
balls: 2, balls: 2,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );

@ -88,6 +88,7 @@ void main() {
score: 0, score: 0,
balls: 0, balls: 0,
activatedBonusLetters: [], activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [], bonusHistory: [],
); );

@ -1,4 +1,3 @@
import 'package:flame/components.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';
@ -9,7 +8,7 @@ extension PinballGameTest on PinballGame {
theme: const PinballTheme( theme: const PinballTheme(
characterTheme: DashTheme(), characterTheme: DashTheme(),
), ),
); )..images.prefix = '';
} }
/// [DebugPinballGame] extension to reduce boilerplate in tests. /// [DebugPinballGame] extension to reduce boilerplate in tests.
@ -21,41 +20,3 @@ extension DebugPinballGameTest on DebugPinballGame {
), ),
); );
} }
extension ComponentX on Component {
T findNestedChild<T extends Component>({
bool Function(T)? condition,
}) {
T? nestedChild;
propagateToChildren<T>((child) {
final foundChild = (condition ?? (_) => true)(child);
if (foundChild) {
nestedChild = child;
}
return !foundChild;
});
if (nestedChild == null) {
throw Exception('No child of type $T found.');
} else {
return nestedChild!;
}
}
List<T> findNestedChildren<T extends Component>({
bool Function(T)? condition,
}) {
final nestedChildren = <T>[];
propagateToChildren<T>((child) {
final foundChild = (condition ?? (_) => true)(child);
if (foundChild) {
nestedChildren.add(child);
}
return true;
});
return nestedChildren;
}
}

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