diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..37ec09f9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Every request must be reviewed and accepted by: + +* @erickzanardo @alestiago @RuiMiguel @allisonryan0002 \ No newline at end of file diff --git a/.github/workflows/geometry.yaml b/.github/workflows/geometry.yaml new file mode 100644 index 00000000..8bf55107 --- /dev/null +++ b/.github/workflows/geometry.yaml @@ -0,0 +1,18 @@ +name: geometry + +on: + push: + paths: + - "packages/geometry/**" + - ".github/workflows/geometry.yaml" + + pull_request: + paths: + - "packages/geometry/**" + - ".github/workflows/geometry.yaml" + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + with: + working_directory: packages/geometry diff --git a/.github/workflows/pinball_theme.yaml b/.github/workflows/pinball_theme.yaml index f6fa14aa..83206de5 100644 --- a/.github/workflows/pinball_theme.yaml +++ b/.github/workflows/pinball_theme.yaml @@ -15,4 +15,5 @@ jobs: build: uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 with: - working_directory: packages/pinball_theme \ No newline at end of file + working_directory: packages/pinball_theme + coverage_excludes: "lib/src/generated/*.dart" \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 07aa1dab..44aef9ac 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1 @@ include: package:very_good_analysis/analysis_options.2.4.0.yaml -linter: - rules: - public_member_api_docs: false diff --git a/assets/images/components/flipper.png b/assets/images/components/flipper.png new file mode 100644 index 00000000..f63974c4 Binary files /dev/null and b/assets/images/components/flipper.png differ diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 7e3fdf17..cf6213e9 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -5,6 +5,8 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +// ignore_for_file: public_member_api_docs + import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:pinball/l10n/l10n.dart'; diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index c612b584..34fcc47a 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -5,6 +5,8 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +// ignore_for_file: public_member_api_docs + import 'dart:async'; import 'dart:developer'; diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index 3b5c16b0..74685215 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -1,3 +1,5 @@ +// ignore_for_file: public_member_api_docs + import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; @@ -12,6 +14,8 @@ class GameBloc extends Bloc { on(_onBonusLetterActivated); } + static const bonusWord = 'GOOGLE'; + void _onBallLost(BallLost event, Emitter emit) { if (state.balls > 0) { emit(state.copyWith(balls: state.balls - 1)); @@ -25,13 +29,25 @@ class GameBloc extends Bloc { } void _onBonusLetterActivated(BonusLetterActivated event, Emitter emit) { - emit( - state.copyWith( - bonusLetters: [ - ...state.bonusLetters, - event.letter, - ], - ), - ); + final newBonusLetters = [ + ...state.activatedBonusLetters, + event.letterIndex, + ]; + + if (newBonusLetters.length == bonusWord.length) { + emit( + state.copyWith( + activatedBonusLetters: [], + bonusHistory: [ + ...state.bonusHistory, + GameBonus.word, + ], + ), + ); + } else { + emit( + state.copyWith(activatedBonusLetters: newBonusLetters), + ); + } } } diff --git a/lib/game/bloc/game_event.dart b/lib/game/bloc/game_event.dart index 417f6322..0edc91ab 100644 --- a/lib/game/bloc/game_event.dart +++ b/lib/game/bloc/game_event.dart @@ -1,3 +1,5 @@ +// ignore_for_file: public_member_api_docs + part of 'game_bloc.dart'; @immutable @@ -5,16 +7,22 @@ abstract class GameEvent extends Equatable { const GameEvent(); } +/// {@template ball_lost_game_event} /// Event added when a user drops a ball off the screen. +/// {@endtemplate} class BallLost extends GameEvent { + /// {@macro ball_lost_game_event} const BallLost(); @override List get props => []; } +/// {@template scored_game_event} /// Event added when a user increases their score. +/// {@endtemplate} class Scored extends GameEvent { + /// {@macro scored_game_event} const Scored({ required this.points, }) : assert(points > 0, 'Points must be greater than 0'); @@ -26,10 +34,14 @@ class Scored extends GameEvent { } class BonusLetterActivated extends GameEvent { - const BonusLetterActivated(this.letter); + const BonusLetterActivated(this.letterIndex) + : assert( + letterIndex < GameBloc.bonusWord.length, + 'Index must be smaller than the length of the word', + ); - final String letter; + final int letterIndex; @override - List get props => [letter]; + List get props => [letterIndex]; } diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 8a5ab298..e2c39d1f 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -1,5 +1,14 @@ +// ignore_for_file: public_member_api_docs + part of 'game_bloc.dart'; +/// Defines bonuses that a player can gain during a PinballGame. +enum GameBonus { + /// Bonus achieved when the user activate all of the bonus + /// letters on the board, forming the bonus word + word, +} + /// {@template game_state} /// Represents the state of the pinball game. /// {@endtemplate} @@ -8,14 +17,16 @@ class GameState extends Equatable { const GameState({ required this.score, required this.balls, - required this.bonusLetters, + required this.activatedBonusLetters, + required this.bonusHistory, }) : assert(score >= 0, "Score can't be negative"), assert(balls >= 0, "Number of balls can't be negative"); const GameState.initial() : score = 0, balls = 3, - bonusLetters = const []; + activatedBonusLetters = const [], + bonusHistory = const []; /// The current score of the game. final int score; @@ -26,7 +37,11 @@ class GameState extends Equatable { final int balls; /// Active bonus letters. - final List bonusLetters; + final List activatedBonusLetters; + + /// Holds the history of all the [GameBonus]es earned by the player during a + /// PinballGame. + final List bonusHistory; /// Determines when the game is over. bool get isGameOver => balls == 0; @@ -34,10 +49,15 @@ class GameState extends Equatable { /// Determines when the player has only one ball left. bool get isLastBall => balls == 1; + /// Shortcut method to check if the given [i] + /// is activated. + bool isLetterActivated(int i) => activatedBonusLetters.contains(i); + GameState copyWith({ int? score, int? balls, - List? bonusLetters, + List? activatedBonusLetters, + List? bonusHistory, }) { assert( score == null || score >= this.score, @@ -47,7 +67,9 @@ class GameState extends Equatable { return GameState( score: score ?? this.score, balls: balls ?? this.balls, - bonusLetters: bonusLetters ?? this.bonusLetters, + activatedBonusLetters: + activatedBonusLetters ?? this.activatedBonusLetters, + bonusHistory: bonusHistory ?? this.bonusHistory, ); } @@ -55,6 +77,7 @@ class GameState extends Equatable { List get props => [ score, balls, - bonusLetters, + activatedBonusLetters, + bonusHistory, ]; } diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index 2d9dddf0..d5b05fa1 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -1,48 +1,59 @@ import 'package:flame/components.dart'; -import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/game.dart'; -class Ball extends PositionBodyComponent - with BlocComponent { +/// {@template ball} +/// A solid, [BodyType.dynamic] sphere that rolls and bounces along the +/// [PinballGame]. +/// {@endtemplate} +class Ball extends PositionBodyComponent { + /// {@macro ball} Ball({ required Vector2 position, }) : _position = position, - super(size: ballSize); - - static final ballSize = Vector2.all(2); + super(size: Vector2.all(2)); + /// The initial position of the [Ball] body. final Vector2 _position; + /// Asset location of the sprite that renders with the [Ball]. + /// + /// Sprite is preloaded by [PinballGameAssetsX]. static const spritePath = 'components/ball.png'; @override Future onLoad() async { await super.onLoad(); final sprite = await gameRef.loadSprite(spritePath); - positionComponent = SpriteComponent(sprite: sprite, size: ballSize); + final tint = gameRef.theme.characterTheme.ballColor.withOpacity(0.5); + positionComponent = SpriteComponent(sprite: sprite, size: size)..tint(tint); } @override Body createBody() { - final shape = CircleShape()..radius = ballSize.x / 2; + final shape = CircleShape()..radius = size.x / 2; final fixtureDef = FixtureDef(shape)..density = 1; final bodyDef = BodyDef() ..userData = this - ..position = _position + ..position = Vector2(_position.x, _position.y + size.y) ..type = BodyType.dynamic; return world.createBody(bodyDef)..createFixture(fixtureDef); } + /// Removes the [Ball] from a [PinballGame]; spawning a new [Ball] if + /// any are left. + /// + /// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into + /// a [BottomWall]. void lost() { shouldRemove = true; final bloc = gameRef.read()..add(const BallLost()); - final shouldBallRespwan = !bloc.state.isLastBall; + final shouldBallRespwan = !bloc.state.isLastBall && !bloc.state.isGameOver; if (shouldBallRespwan) { gameRef.spawnBall(); } diff --git a/lib/game/components/baseboard.dart b/lib/game/components/baseboard.dart new file mode 100644 index 00000000..9153d4f3 --- /dev/null +++ b/lib/game/components/baseboard.dart @@ -0,0 +1,91 @@ +import 'dart:math' as math; + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template baseboard} +/// Straight, angled board piece to corral the [Ball] towards the [Flipper]s. +/// {@endtemplate} +class Baseboard extends BodyComponent { + /// {@macro baseboard} + Baseboard._({ + required Vector2 position, + required BoardSide side, + }) : _position = position, + _side = side; + + /// A left positioned [Baseboard]. + Baseboard.left({ + required Vector2 position, + }) : this._( + position: position, + side: BoardSide.left, + ); + + /// A right positioned [Baseboard]. + Baseboard.right({ + required Vector2 position, + }) : this._( + position: position, + side: BoardSide.right, + ); + + /// The width of the [Baseboard]. + static const width = 10.0; + + /// The height of the [Baseboard]. + static const height = 2.0; + + /// The position of the [Baseboard] body. + final Vector2 _position; + + /// Whether the [Baseboard] is on the left or right side of the board. + final BoardSide _side; + + List _createFixtureDefs() { + final fixtures = []; + + final circleShape1 = CircleShape()..radius = Baseboard.height / 2; + circleShape1.position.setValues( + -(Baseboard.width / 2) + circleShape1.radius, + 0, + ); + final circle1FixtureDef = FixtureDef(circleShape1); + fixtures.add(circle1FixtureDef); + + final circleShape2 = CircleShape()..radius = Baseboard.height / 2; + circleShape2.position.setValues( + (Baseboard.width / 2) - circleShape2.radius, + 0, + ); + final circle2FixtureDef = FixtureDef(circleShape2); + fixtures.add(circle2FixtureDef); + + final rectangle = PolygonShape() + ..setAsBoxXY( + (Baseboard.width - Baseboard.height) / 2, + Baseboard.height / 2, + ); + final rectangleFixtureDef = FixtureDef(rectangle); + fixtures.add(rectangleFixtureDef); + + return fixtures; + } + + @override + Body createBody() { + // TODO(allisonryan0002): share sweeping angle with flipper when components + // are grouped. + const angle = math.pi / 7; + + final bodyDef = BodyDef() + ..position = _position + ..type = BodyType.static + ..angle = _side.isLeft ? -angle : angle; + + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + + return body; + } +} diff --git a/lib/game/components/bonus_word.dart b/lib/game/components/bonus_word.dart new file mode 100644 index 00000000..35412ecf --- /dev/null +++ b/lib/game/components/bonus_word.dart @@ -0,0 +1,134 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template bonus_word} +/// Loads all [BonusLetter]s to compose a [BonusWord]. +/// {@endtemplate} +class BonusWord extends Component { + /// {@macro bonus_word} + BonusWord({required Vector2 position}) : _position = position; + + final Vector2 _position; + + @override + Future onLoad() async { + await super.onLoad(); + final letters = GameBloc.bonusWord.split(''); + + for (var i = 0; i < letters.length; i++) { + unawaited( + add( + BonusLetter( + position: _position - Vector2(16 - (i * 6), -30), + letter: letters[i], + index: i, + ), + ), + ); + } + } +} + +/// {@template bonus_letter} +/// [BodyType.static] sensor component, part of a word bonus, +/// which will activate its letter after contact with a [Ball]. +/// {@endtemplate} +class BonusLetter extends BodyComponent + with BlocComponent { + /// {@macro bonus_letter} + BonusLetter({ + required Vector2 position, + required String letter, + required int index, + }) : _position = position, + _letter = letter, + _index = index { + paint = Paint()..color = _disableColor; + } + + /// The area size of this [BonusLetter]. + static final areaSize = Vector2.all(4); + + static const _activeColor = Colors.green; + static const _disableColor = Colors.red; + + final Vector2 _position; + final String _letter; + final int _index; + + @override + Future onLoad() async { + await super.onLoad(); + + await add( + TextComponent( + position: Vector2(-1, -1), + text: _letter, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 2, color: Colors.white), + ), + ), + ); + } + + @override + Body createBody() { + final shape = CircleShape()..radius = areaSize.x / 2; + + final fixtureDef = FixtureDef(shape)..isSensor = true; + + final bodyDef = BodyDef() + ..userData = this + ..position = _position + ..type = BodyType.static; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } + + @override + bool listenWhen(GameState? previousState, GameState newState) { + final wasActive = previousState?.isLetterActivated(_index) ?? false; + final isActive = newState.isLetterActivated(_index); + + return wasActive != isActive; + } + + @override + void onNewState(GameState state) { + final isActive = state.isLetterActivated(_index); + + add( + ColorEffect( + isActive ? _activeColor : _disableColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ); + } + + /// Activates this [BonusLetter], if it's not already activated. + void activate() { + final isActive = state?.isLetterActivated(_index) ?? false; + if (!isActive) { + gameRef.read().add(BonusLetterActivated(_index)); + } + } +} + +/// Triggers [BonusLetter.activate] method when a [BonusLetter] and a [Ball] +/// come in contact. +class BonusLetterBallContactCallback + extends ContactCallback { + @override + void begin(Ball ball, BonusLetter bonusLetter, Contact contact) { + bonusLetter.activate(); + } +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 89f60343..6e663c26 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,6 +1,8 @@ export 'anchor.dart'; export 'ball.dart'; +export 'baseboard.dart'; export 'board_side.dart'; +export 'bonus_word.dart'; export 'flipper.dart'; export 'pathway.dart'; export 'plunger.dart'; diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart index bd071b93..6c184383 100644 --- a/lib/game/components/flipper.dart +++ b/lib/game/components/flipper.dart @@ -1,30 +1,59 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flame/components.dart' show PositionComponent, SpriteComponent; import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pinball/game/game.dart'; +/// {@template flipper_group} +/// Loads a [Flipper.right] and a [Flipper.left]. +/// {@endtemplate} +class FlipperGroup extends PositionComponent { + /// {@macro flipper_group} + FlipperGroup({ + required Vector2 position, + required this.spacing, + }) : super(position: position); + + /// The amount of space between the [Flipper.right] and [Flipper.left]. + final double spacing; + + @override + Future onLoad() async { + final leftFlipper = Flipper.left( + position: Vector2( + position.x - (Flipper.width / 2) - (spacing / 2), + position.y, + ), + ); + await add(leftFlipper); + + final rightFlipper = Flipper.right( + position: Vector2( + position.x + (Flipper.width / 2) + (spacing / 2), + position.y, + ), + ); + await add(rightFlipper); + } +} + /// {@template flipper} /// A bat, typically found in pairs at the bottom of the board. /// /// [Flipper] can be controlled by the player in an arc motion. /// {@endtemplate flipper} -class Flipper extends BodyComponent with KeyboardHandler { +class Flipper extends PositionBodyComponent with KeyboardHandler { /// {@macro flipper} Flipper._({ required Vector2 position, required this.side, required List keys, }) : _position = position, - _keys = keys { - // TODO(alestiago): Use sprite instead of color when provided. - paint = Paint() - ..color = const Color(0xFF00FF00) - ..style = PaintingStyle.fill; - } + _keys = keys, + super(size: Vector2(width, height)); /// A left positioned [Flipper]. Flipper.left({ @@ -50,6 +79,11 @@ class Flipper extends BodyComponent with KeyboardHandler { ], ); + /// Asset location of the sprite that renders with the [Flipper]. + /// + /// Sprite is preloaded by [PinballGameAssetsX]. + static const spritePath = 'components/flipper.png'; + /// The width of the [Flipper]. static const width = 12.0; @@ -87,15 +121,50 @@ class Flipper extends BodyComponent with KeyboardHandler { body.linearVelocity = Vector2(0, _speed); } + /// Loads the sprite that renders with the [Flipper]. + Future _loadSprite() async { + final sprite = await gameRef.loadSprite(spritePath); + positionComponent = SpriteComponent( + sprite: sprite, + size: size, + ); + + if (side.isRight) { + positionComponent!.flipHorizontally(); + } + } + + /// Anchors the [Flipper] to the [RevoluteJoint] that controls its arc motion. + Future _anchorToJoint() async { + final anchor = FlipperAnchor(flipper: this); + await add(anchor); + + final jointDef = FlipperAnchorRevoluteJointDef( + flipper: this, + anchor: anchor, + ); + // TODO(alestiago): Remove casting once the following is closed: + // https://github.com/flame-engine/forge2d/issues/36 + final joint = world.createJoint(jointDef) as RevoluteJoint; + + // FIXME(erickzanardo): when mounted the initial position is not fully + // reached. + unawaited( + mounted.whenComplete( + () => FlipperAnchorRevoluteJointDef.unlock(joint, side), + ), + ); + } + List _createFixtureDefs() { final fixtures = []; final isLeft = side.isLeft; - final bigCircleShape = CircleShape()..radius = height / 2; + final bigCircleShape = CircleShape()..radius = size.y / 2; bigCircleShape.position.setValues( isLeft - ? -(width / 2) + bigCircleShape.radius - : (width / 2) - bigCircleShape.radius, + ? -(size.x / 2) + bigCircleShape.radius + : (size.x / 2) - bigCircleShape.radius, 0, ); final bigCircleFixtureDef = FixtureDef(bigCircleShape); @@ -104,8 +173,8 @@ class Flipper extends BodyComponent with KeyboardHandler { final smallCircleShape = CircleShape()..radius = bigCircleShape.radius / 2; smallCircleShape.position.setValues( isLeft - ? (width / 2) - smallCircleShape.radius - : -(width / 2) + smallCircleShape.radius, + ? (size.x / 2) - smallCircleShape.radius + : -(size.x / 2) + smallCircleShape.radius, 0, ); final smallCircleFixtureDef = FixtureDef(smallCircleShape); @@ -133,6 +202,15 @@ class Flipper extends BodyComponent with KeyboardHandler { return fixtures; } + @override + Future onLoad() async { + await super.onLoad(); + await Future.wait([ + _loadSprite(), + _anchorToJoint(), + ]); + } + @override Body createBody() { final bodyDef = BodyDef() @@ -146,16 +224,6 @@ class Flipper extends BodyComponent with KeyboardHandler { return body; } - // TODO(erickzanardo): Remove this once the issue is solved: - // https://github.com/flame-engine/flame/issues/1417 - final Completer hasMounted = Completer(); - - @override - void onMount() { - super.onMount(); - hasMounted.complete(); - } - @override bool onKeyEvent( RawKeyEvent event, @@ -188,8 +256,8 @@ class FlipperAnchor extends Anchor { }) : super( position: Vector2( flipper.side.isLeft - ? flipper.body.position.x - Flipper.width / 2 - : flipper.body.position.x + Flipper.width / 2, + ? flipper.body.position.x - flipper.size.x / 2 + : flipper.body.position.x + flipper.size.x / 2, flipper.body.position.y, ), ); @@ -202,7 +270,7 @@ class FlipperAnchorRevoluteJointDef extends RevoluteJointDef { /// {@macro flipper_anchor_revolute_joint_def} FlipperAnchorRevoluteJointDef({ required Flipper flipper, - required Anchor anchor, + required FlipperAnchor anchor, }) { initialize( flipper.body, diff --git a/lib/game/components/plunger.dart b/lib/game/components/plunger.dart index 364fc35e..2ec2f599 100644 --- a/lib/game/components/plunger.dart +++ b/lib/game/components/plunger.dart @@ -1,23 +1,32 @@ +import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/services.dart'; import 'package:pinball/game/game.dart' show Anchor; /// {@template plunger} /// [Plunger] serves as a spring, that shoots the ball on the right side of the /// playfield. /// -/// [Plunger] ignores gravity so the player controls its downward [pull]. +/// [Plunger] ignores gravity so the player controls its downward [_pull]. /// {@endtemplate} -class Plunger extends BodyComponent { +class Plunger extends BodyComponent with KeyboardHandler { /// {@macro plunger} - Plunger({required Vector2 position}) : _position = position; + Plunger({ + required Vector2 position, + required this.compressionDistance, + }) : _position = position; + /// The initial position of the [Plunger] body. final Vector2 _position; + /// Distance the plunger can lower. + final double compressionDistance; + @override Body createBody() { - final shape = PolygonShape()..setAsBoxXY(2.5, 1.5); + final shape = PolygonShape()..setAsBoxXY(2, 0.75); - final fixtureDef = FixtureDef(shape); + final fixtureDef = FixtureDef(shape)..density = 5; final bodyDef = BodyDef() ..userData = this @@ -29,18 +38,57 @@ class Plunger extends BodyComponent { } /// Set a constant downward velocity on the [Plunger]. - void pull() { - body.linearVelocity = Vector2(0, -7); + void _pull() { + body.linearVelocity = Vector2(0, -3); } /// Set an upward velocity on the [Plunger]. /// /// The velocity's magnitude depends on how far the [Plunger] has been pulled /// from its original [_position]. - void release() { + void _release() { final velocity = (_position.y - body.position.y) * 9; body.linearVelocity = Vector2(0, velocity); } + + @override + bool onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + final keys = [ + LogicalKeyboardKey.space, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.keyS, + ]; + // TODO(alestiago): Check why false cancels the event for other components. + // Investigate why return is of type [bool] expected instead of a type + // [KeyEventResult]. + if (!keys.contains(event.logicalKey)) return true; + + if (event is RawKeyDownEvent) { + _pull(); + } else if (event is RawKeyUpEvent) { + _release(); + } + + return true; + } +} + +/// {@template plunger_anchor} +/// [Anchor] positioned below a [Plunger]. +/// {@endtemplate} +class PlungerAnchor extends Anchor { + /// {@macro plunger_anchor} + PlungerAnchor({ + required Plunger plunger, + }) : super( + position: Vector2( + plunger.body.position.x, + plunger.body.position.y - plunger.compressionDistance, + ), + ); } /// {@template plunger_anchor_prismatic_joint_def} @@ -54,11 +102,8 @@ class PlungerAnchorPrismaticJointDef extends PrismaticJointDef { /// {@macro plunger_anchor_prismatic_joint_def} PlungerAnchorPrismaticJointDef({ required Plunger plunger, - required Anchor anchor, - }) : assert( - anchor.body.position.y < plunger.body.position.y, - 'Anchor must be below the Plunger', - ) { + required PlungerAnchor anchor, + }) { initialize( plunger.body, anchor.body, @@ -67,6 +112,9 @@ class PlungerAnchorPrismaticJointDef extends PrismaticJointDef { ); enableLimit = true; lowerTranslation = double.negativeInfinity; + enableMotor = true; + motorSpeed = 50; + maxMotorForce = motorSpeed; collideConnected = true; } } diff --git a/lib/game/components/score_points.dart b/lib/game/components/score_points.dart index c6474b16..b4ad1bdf 100644 --- a/lib/game/components/score_points.dart +++ b/lib/game/components/score_points.dart @@ -22,9 +22,4 @@ class BallScorePointsCallback extends ContactCallback { ) { ball.gameRef.read().add(Scored(points: scorePoints.points)); } - - // TODO(alestiago): remove once this issue is closed. - // https://github.com/flame-engine/flame/issues/1414 - @override - void end(Ball _, ScorePoints __, Contact ___) {} } diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart index b784b8cb..c433365c 100644 --- a/lib/game/components/wall.dart +++ b/lib/game/components/wall.dart @@ -4,15 +4,20 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/components/components.dart'; /// {@template wall} -/// A continuos generic and [BodyType.static] barrier that divides a game area. +/// A continuous generic and [BodyType.static] barrier that divides a game area. /// {@endtemplate} +// TODO(alestiago): Remove [Wall] for [Pathway.straight]. class Wall extends BodyComponent { + /// {@macro wall} Wall({ required this.start, required this.end, }); + /// The [start] of the [Wall]. final Vector2 start; + + /// The [end] of the [Wall]. final Vector2 end; @override @@ -20,7 +25,7 @@ class Wall extends BodyComponent { final shape = EdgeShape()..set(start, end); final fixtureDef = FixtureDef(shape) - ..restitution = 0.0 + ..restitution = 0.1 ..friction = 0.3; final bodyDef = BodyDef() @@ -32,6 +37,20 @@ class Wall extends BodyComponent { } } +/// Create top, left, and right [Wall]s for the game board. +List createBoundaries(Forge2DGame game) { + final topLeft = Vector2.zero(); + final bottomRight = game.screenToWorld(game.camera.viewport.effectiveSize); + final topRight = Vector2(bottomRight.x, topLeft.y); + final bottomLeft = Vector2(topLeft.x, bottomRight.y); + + return [ + Wall(start: topLeft, end: topRight), + Wall(start: topRight, end: bottomRight), + Wall(start: bottomLeft, end: topLeft), + ]; +} + /// {@template bottom_wall} /// [Wall] located at the bottom of the board. /// @@ -39,6 +58,7 @@ class Wall extends BodyComponent { /// [BottomWallBallContactCallback]. /// {@endtemplate} class BottomWall extends Wall { + /// {@macro bottom_wall} BottomWall(Forge2DGame game) : super( start: game.screenToWorld(game.camera.viewport.effectiveSize), @@ -57,7 +77,4 @@ class BottomWallBallContactCallback extends ContactCallback { void begin(Ball ball, BottomWall wall, Contact contact) { ball.lost(); } - - @override - void end(_, __, ___) {} } diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index 964aeda1..778e2bc2 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -6,6 +6,7 @@ extension PinballGameAssetsX on PinballGame { Future preLoadAssets() async { await Future.wait([ images.load(Ball.spritePath), + images.load(Flipper.spritePath), ]); } } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 501ea514..633c2351 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -1,22 +1,19 @@ +// ignore_for_file: public_member_api_docs + import 'dart:async'; import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_theme/pinball_theme.dart'; class PinballGame extends Forge2DGame with FlameBloc, HasKeyboardHandlerComponents { - // TODO(erickzanardo): Change to the plumber position - late final ballStartingPosition = screenToWorld( - Vector2( - camera.viewport.effectiveSize.x / 2, - camera.viewport.effectiveSize.y - 20, - ), - ) - - Vector2(0, -20); + PinballGame({required this.theme}); + + final PinballTheme theme; - // TODO(alestiago): Change to the design position. - late final flippersPosition = ballStartingPosition - Vector2(0, 5); + late final Plunger plunger; @override void onAttach() { @@ -24,77 +21,141 @@ class PinballGame extends Forge2DGame spawnBall(); } - void spawnBall() { - add(Ball(position: ballStartingPosition)); - } - @override Future onLoad() async { - addContactCallback(BallScorePointsCallback()); + _addContactCallbacks(); - await add(BottomWall(this)); - addContactCallback(BottomWallBallContactCallback()); + await _addGameBoundaries(); + unawaited(_addPlunger()); + + // Corner wall above plunger so the ball deflects into the rest of the + // board. + // TODO(allisonryan0002): remove once we have the launch track for the ball. + await add( + Wall( + start: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x, + 100, + ), + ), + end: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x - 100, + 0, + ), + ), + ), + ); unawaited(_addFlippers()); + + unawaited(_addBonusWord()); + } + + Future _addBonusWord() async { + await add( + BonusWord( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2, + camera.viewport.effectiveSize.y - 50, + ), + ), + ), + ); } Future _addFlippers() async { - const spaceBetweenFlippers = 2; - final leftFlipper = Flipper.left( - position: Vector2( - flippersPosition.x - (Flipper.width / 2) - (spaceBetweenFlippers / 2), - flippersPosition.y, + final flippersPosition = screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2, + camera.viewport.effectiveSize.y / 1.1, ), ); - await add(leftFlipper); - final leftFlipperAnchor = FlipperAnchor(flipper: leftFlipper); - await add(leftFlipperAnchor); - final leftFlipperRevoluteJointDef = FlipperAnchorRevoluteJointDef( - flipper: leftFlipper, - anchor: leftFlipperAnchor, + + unawaited( + add( + FlipperGroup( + position: flippersPosition, + spacing: 2, + ), + ), ); - // TODO(alestiago): Remove casting once the following is closed: - // https://github.com/flame-engine/forge2d/issues/36 - final leftFlipperRevoluteJoint = - world.createJoint(leftFlipperRevoluteJointDef) as RevoluteJoint; - - final rightFlipper = Flipper.right( - position: Vector2( - flippersPosition.x + (Flipper.width / 2) + (spaceBetweenFlippers / 2), - flippersPosition.y, + unawaited(_addBaseboards()); + } + + void spawnBall() { + add(Ball(position: plunger.body.position)); + } + + void _addContactCallbacks() { + addContactCallback(BallScorePointsCallback()); + addContactCallback(BottomWallBallContactCallback()); + addContactCallback(BonusLetterBallContactCallback()); + } + + Future _addGameBoundaries() async { + await add(BottomWall(this)); + createBoundaries(this).forEach(add); + } + + Future _addPlunger() async { + late PlungerAnchor plungerAnchor; + final compressionDistance = camera.viewport.effectiveSize.y / 12; + + await add( + plunger = Plunger( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 1.035, + camera.viewport.effectiveSize.y - compressionDistance, + ), + ), + compressionDistance: compressionDistance, ), ); - await add(rightFlipper); - final rightFlipperAnchor = FlipperAnchor(flipper: rightFlipper); - await add(rightFlipperAnchor); - final rightFlipperRevoluteJointDef = FlipperAnchorRevoluteJointDef( - flipper: rightFlipper, - anchor: rightFlipperAnchor, + await add(plungerAnchor = PlungerAnchor(plunger: plunger)); + + world.createJoint( + PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: plungerAnchor, + ), ); - // TODO(alestiago): Remove casting once the following is closed: - // https://github.com/flame-engine/forge2d/issues/36 - final rightFlipperRevoluteJoint = - world.createJoint(rightFlipperRevoluteJointDef) as RevoluteJoint; - - // TODO(erickzanardo): Clean this once the issue is solved: - // https://github.com/flame-engine/flame/issues/1417 - // FIXME(erickzanardo): when mounted the initial position is not fully - // reached. - unawaited( - leftFlipper.hasMounted.future.whenComplete( - () => FlipperAnchorRevoluteJointDef.unlock( - leftFlipperRevoluteJoint, - leftFlipper.side, + } + + Future _addBaseboards() async { + final spaceBetweenBaseboards = camera.viewport.effectiveSize.x / 2; + final baseboardY = camera.viewport.effectiveSize.y / 1.12; + + final leftBaseboard = Baseboard.left( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 - (spaceBetweenBaseboards / 2), + baseboardY, ), ), ); - unawaited( - rightFlipper.hasMounted.future.whenComplete( - () => FlipperAnchorRevoluteJointDef.unlock( - rightFlipperRevoluteJoint, - rightFlipper.side, + await add(leftBaseboard); + + final rightBaseboard = Baseboard.right( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 + (spaceBetweenBaseboards / 2), + baseboardY, ), ), ); + await add(rightBaseboard); + } +} + +class DebugPinballGame extends PinballGame with TapDetector { + DebugPinballGame({required PinballTheme theme}) : super(theme: theme); + + @override + void onTapUp(TapUpInfo info) { + add(Ball(position: info.eventPosition.game)); } } diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 8a9a981c..21bd4074 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -1,17 +1,23 @@ +// ignore_for_file: public_member_api_docs + import 'package:flame/game.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_theme/pinball_theme.dart'; class PinballGamePage extends StatelessWidget { - const PinballGamePage({Key? key}) : super(key: key); + const PinballGamePage({Key? key, required this.theme}) : super(key: key); + + final PinballTheme theme; - static Route route() { + static Route route({required PinballTheme theme}) { return MaterialPageRoute( builder: (_) { return BlocProvider( create: (_) => GameBloc(), - child: const PinballGamePage(), + child: PinballGamePage(theme: theme), ); }, ); @@ -19,12 +25,20 @@ class PinballGamePage extends StatelessWidget { @override Widget build(BuildContext context) { - return const PinballGameView(); + return PinballGameView(theme: theme); } } class PinballGameView extends StatefulWidget { - const PinballGameView({Key? key}) : super(key: key); + const PinballGameView({ + Key? key, + required this.theme, + bool isDebugMode = kDebugMode, + }) : _isDebugMode = isDebugMode, + super(key: key); + + final PinballTheme theme; + final bool _isDebugMode; @override State createState() => _PinballGameViewState(); @@ -40,12 +54,17 @@ class _PinballGameViewState extends State { // TODO(erickzanardo): Revisit this when we start to have more assets // this could expose a Stream (maybe even a cubit?) so we could show the // the loading progress with some fancy widgets. - _game = PinballGame()..preLoadAssets(); + _game = (widget._isDebugMode + ? DebugPinballGame(theme: widget.theme) + : PinballGame(theme: widget.theme)) + ..preLoadAssets(); } @override Widget build(BuildContext context) { return BlocListener( + listenWhen: (previous, current) => + previous.isGameOver != current.isGameOver, listener: (context, state) { if (state.isGameOver) { showDialog( diff --git a/lib/game/view/view.dart b/lib/game/view/view.dart index 26b700d3..53d3813a 100644 --- a/lib/game/view/view.dart +++ b/lib/game/view/view.dart @@ -1,3 +1,2 @@ -export 'game_hud.dart'; export 'pinball_game_page.dart'; export 'widgets/widgets.dart'; diff --git a/lib/game/view/game_hud.dart b/lib/game/view/widgets/game_hud.dart similarity index 100% rename from lib/game/view/game_hud.dart rename to lib/game/view/widgets/game_hud.dart diff --git a/lib/game/view/widgets/game_over_dialog.dart b/lib/game/view/widgets/game_over_dialog.dart index 586d6c56..9d1c61b0 100644 --- a/lib/game/view/widgets/game_over_dialog.dart +++ b/lib/game/view/widgets/game_over_dialog.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +/// {@template game_over_dialog} +/// [Dialog] displayed when the [PinballGame] is over. +/// {@endtemplate} class GameOverDialog extends StatelessWidget { + /// {@macro game_over_dialog} const GameOverDialog({Key? key}) : super(key: key); @override diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart index 9c457b1c..aa473c64 100644 --- a/lib/game/view/widgets/widgets.dart +++ b/lib/game/view/widgets/widgets.dart @@ -1 +1,2 @@ +export 'game_hud.dart'; export 'game_over_dialog.dart'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index fa732ac7..f12ccf7d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,15 @@ { "@@locale": "en", - "play": "Play" + "play": "Play", + "@play": { + "description": "Text displayed on the landing page play button" + }, + "start": "Start", + "@start": { + "description": "Text displayed on the character selection page start button" + }, + "characterSelectionTitle": "Choose your character!", + "@characterSelectionTitle": { + "description": "Title text displayed on the character selection page" + } } \ No newline at end of file diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 405ef51f..597a39d8 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,4 +1,15 @@ { "@@locale": "es", - "play": "Jugar" + "play": "Jugar", + "@play": { + "description": "Text displayed on the landing page play button" + }, + "start": "Comienzo", + "@start": { + "description": "Text displayed on the character selection page start button" + }, + "characterSelectionTitle": "¡Elige a tu personaje!", + "@characterSelectionTitle": { + "description": "Title text displayed on the character selection page" + } } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 766b5e31..548a81a6 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -5,6 +5,8 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +// ignore_for_file: public_member_api_docs + import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; diff --git a/lib/landing/view/landing_page.dart b/lib/landing/view/landing_page.dart index a688dee1..38951da6 100644 --- a/lib/landing/view/landing_page.dart +++ b/lib/landing/view/landing_page.dart @@ -1,6 +1,8 @@ +// ignore_for_file: public_member_api_docs + import 'package:flutter/material.dart'; -import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/theme/theme.dart'; class LandingPage extends StatelessWidget { const LandingPage({Key? key}) : super(key: key); @@ -8,11 +10,12 @@ class LandingPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + return Scaffold( body: Center( child: TextButton( onPressed: () => - Navigator.of(context).push(PinballGamePage.route()), + Navigator.of(context).push(CharacterSelectionPage.route()), child: Text(l10n.play), ), ), diff --git a/lib/theme/cubit/theme_cubit.dart b/lib/theme/cubit/theme_cubit.dart index 7ba79e59..94eba4a6 100644 --- a/lib/theme/cubit/theme_cubit.dart +++ b/lib/theme/cubit/theme_cubit.dart @@ -1,3 +1,6 @@ +// ignore_for_file: public_member_api_docs +// TODO(allisonryan0002): Document this section when the API is stable. + import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:pinball_theme/pinball_theme.dart'; diff --git a/lib/theme/cubit/theme_state.dart b/lib/theme/cubit/theme_state.dart index 13b3ea5f..078f5c84 100644 --- a/lib/theme/cubit/theme_state.dart +++ b/lib/theme/cubit/theme_state.dart @@ -1,3 +1,6 @@ +// ignore_for_file: public_member_api_docs +// TODO(allisonryan0002): Document this section when the API is stable. + part of 'theme_cubit.dart'; class ThemeState extends Equatable { diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index fcf5d9ee..f6318400 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -1 +1,2 @@ export 'cubit/theme_cubit.dart'; +export 'view/view.dart'; diff --git a/lib/theme/view/character_selection_page.dart b/lib/theme/view/character_selection_page.dart new file mode 100644 index 00000000..9569760a --- /dev/null +++ b/lib/theme/view/character_selection_page.dart @@ -0,0 +1,130 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/theme/theme.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +class CharacterSelectionPage extends StatelessWidget { + const CharacterSelectionPage({Key? key}) : super(key: key); + + static Route route() { + return MaterialPageRoute( + builder: (_) => const CharacterSelectionPage(), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ThemeCubit(), + child: const CharacterSelectionView(), + ); + } +} + +class CharacterSelectionView extends StatelessWidget { + const CharacterSelectionView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + body: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 80), + Text( + l10n.characterSelectionTitle, + style: Theme.of(context).textTheme.headline3, + ), + const SizedBox(height: 80), + const _CharacterSelectionGridView(), + const SizedBox(height: 20), + TextButton( + onPressed: () => Navigator.of(context).push( + PinballGamePage.route( + theme: context.read().state.theme, + ), + ), + child: Text(l10n.start), + ), + ], + ), + ), + ); + } +} + +class _CharacterSelectionGridView extends StatelessWidget { + const _CharacterSelectionGridView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: GridView.count( + shrinkWrap: true, + crossAxisCount: 2, + mainAxisSpacing: 20, + crossAxisSpacing: 20, + children: const [ + CharacterImageButton( + DashTheme(), + key: Key('characterSelectionPage_dashButton'), + ), + CharacterImageButton( + SparkyTheme(), + key: Key('characterSelectionPage_sparkyButton'), + ), + CharacterImageButton( + AndroidTheme(), + key: Key('characterSelectionPage_androidButton'), + ), + CharacterImageButton( + DinoTheme(), + key: Key('characterSelectionPage_dinoButton'), + ), + ], + ), + ); + } +} + +// TODO(allisonryan0002): remove visibility when adding final UI. +@visibleForTesting +class CharacterImageButton extends StatelessWidget { + const CharacterImageButton( + this.characterTheme, { + Key? key, + }) : super(key: key); + + final CharacterTheme characterTheme; + + @override + Widget build(BuildContext context) { + final currentCharacterTheme = context.select( + (cubit) => cubit.state.theme.characterTheme, + ); + + return GestureDetector( + onTap: () => context.read().characterSelected(characterTheme), + child: DecoratedBox( + decoration: BoxDecoration( + color: (currentCharacterTheme == characterTheme) + ? Colors.blue.withOpacity(0.5) + : null, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: characterTheme.characterAsset.image(), + ), + ), + ); + } +} diff --git a/lib/theme/view/view.dart b/lib/theme/view/view.dart new file mode 100644 index 00000000..1af489b5 --- /dev/null +++ b/lib/theme/view/view.dart @@ -0,0 +1 @@ +export 'character_selection_page.dart'; diff --git a/packages/geometry/lib/src/geometry.dart b/packages/geometry/lib/src/geometry.dart index 0263fc0a..83e2e372 100644 --- a/packages/geometry/lib/src/geometry.dart +++ b/packages/geometry/lib/src/geometry.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; -import 'package:flame/extensions.dart'; + +import 'package:vector_math/vector_math_64.dart'; /// Calculates all [Vector2]s of a circumference. /// diff --git a/packages/geometry/pubspec.yaml b/packages/geometry/pubspec.yaml index 73aef685..da305129 100644 --- a/packages/geometry/pubspec.yaml +++ b/packages/geometry/pubspec.yaml @@ -7,13 +7,9 @@ environment: sdk: ">=2.16.0 <3.0.0" dependencies: - flame: ^1.0.0 - flutter: - sdk: flutter + vector_math: ^2.1.1 dev_dependencies: - flutter_test: - sdk: flutter mocktail: ^0.2.0 test: ^1.19.2 very_good_analysis: ^2.4.0 diff --git a/packages/geometry/test/src/geometry_test.dart b/packages/geometry/test/src/geometry_test.dart index b83d6390..5c33d70f 100644 --- a/packages/geometry/test/src/geometry_test.dart +++ b/packages/geometry/test/src/geometry_test.dart @@ -1,7 +1,8 @@ // ignore_for_file: prefer_const_constructors, cascade_invocations -import 'package:flame/extensions.dart'; -import 'package:flutter_test/flutter_test.dart'; + import 'package:geometry/geometry.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; class Binomial { Binomial({required this.n, required this.k}); @@ -42,18 +43,18 @@ void main() { ], step: 2, ), - throwsAssertionError, + throwsA(isA()), ); }); test('fails if not enough control points', () { expect( () => calculateBezierCurve(controlPoints: [Vector2.zero()]), - throwsAssertionError, + throwsA(isA()), ); expect( () => calculateBezierCurve(controlPoints: []), - throwsAssertionError, + throwsA(isA()), ); }); @@ -81,15 +82,24 @@ void main() { group('binomial', () { test('fails if k is negative', () { - expect(() => binomial(1, -1), throwsAssertionError); + expect( + () => binomial(1, -1), + throwsA(isA()), + ); }); test('fails if n is negative', () { - expect(() => binomial(-1, 1), throwsAssertionError); + expect( + () => binomial(-1, 1), + throwsA(isA()), + ); }); test('fails if n < k', () { - expect(() => binomial(1, 2), throwsAssertionError); + expect( + () => binomial(1, 2), + throwsA(isA()), + ); }); test('for a specific input gives a correct value', () { @@ -131,7 +141,7 @@ void main() { group('factorial', () { test('fails if negative number', () { - expect(() => factorial(-1), throwsAssertionError); + expect(() => factorial(-1), throwsA(isA())); }); test('for a specific input gives a correct value', () { diff --git a/packages/pinball_theme/analysis_options.yaml b/packages/pinball_theme/analysis_options.yaml index 3742fc3d..5e587410 100644 --- a/packages/pinball_theme/analysis_options.yaml +++ b/packages/pinball_theme/analysis_options.yaml @@ -1 +1,4 @@ -include: package:very_good_analysis/analysis_options.2.4.0.yaml \ No newline at end of file +include: package:very_good_analysis/analysis_options.2.4.0.yaml +analyzer: + exclude: + - lib/**/*.gen.dart \ No newline at end of file diff --git a/packages/pinball_theme/assets/images/android.png b/packages/pinball_theme/assets/images/android.png new file mode 100644 index 00000000..23f677a5 Binary files /dev/null and b/packages/pinball_theme/assets/images/android.png differ diff --git a/packages/pinball_theme/assets/images/dash.png b/packages/pinball_theme/assets/images/dash.png new file mode 100644 index 00000000..43c074a3 Binary files /dev/null and b/packages/pinball_theme/assets/images/dash.png differ diff --git a/packages/pinball_theme/assets/images/dino.png b/packages/pinball_theme/assets/images/dino.png new file mode 100644 index 00000000..9e5dbf86 Binary files /dev/null and b/packages/pinball_theme/assets/images/dino.png differ diff --git a/packages/pinball_theme/assets/images/sparky.png b/packages/pinball_theme/assets/images/sparky.png new file mode 100644 index 00000000..8e484f26 Binary files /dev/null and b/packages/pinball_theme/assets/images/sparky.png differ diff --git a/packages/pinball_theme/lib/pinball_theme.dart b/packages/pinball_theme/lib/pinball_theme.dart index 0206fa7b..139a70dc 100644 --- a/packages/pinball_theme/lib/pinball_theme.dart +++ b/packages/pinball_theme/lib/pinball_theme.dart @@ -1,4 +1,5 @@ library pinball_theme; +export 'src/generated/generated.dart'; export 'src/pinball_theme.dart'; export 'src/themes/themes.dart'; diff --git a/packages/pinball_theme/lib/src/generated/assets.gen.dart b/packages/pinball_theme/lib/src/generated/assets.gen.dart new file mode 100644 index 00000000..9dc5c029 --- /dev/null +++ b/packages/pinball_theme/lib/src/generated/assets.gen.dart @@ -0,0 +1,71 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +import 'package:flutter/widgets.dart'; + +class $AssetsImagesGen { + const $AssetsImagesGen(); + + AssetGenImage get android => const AssetGenImage('assets/images/android.png'); + AssetGenImage get dash => const AssetGenImage('assets/images/dash.png'); + AssetGenImage get dino => const AssetGenImage('assets/images/dino.png'); + AssetGenImage get sparky => const AssetGenImage('assets/images/sparky.png'); +} + +class Assets { + Assets._(); + + static const $AssetsImagesGen images = $AssetsImagesGen(); +} + +class AssetGenImage extends AssetImage { + const AssetGenImage(String assetName) + : super(assetName, package: 'pinball_theme'); + + 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; +} diff --git a/packages/pinball_theme/lib/src/generated/generated.dart b/packages/pinball_theme/lib/src/generated/generated.dart new file mode 100644 index 00000000..e7ad4c54 --- /dev/null +++ b/packages/pinball_theme/lib/src/generated/generated.dart @@ -0,0 +1 @@ +export 'assets.gen.dart'; diff --git a/packages/pinball_theme/lib/src/themes/android_theme.dart b/packages/pinball_theme/lib/src/themes/android_theme.dart index 59c16bd9..f6605f52 100644 --- a/packages/pinball_theme/lib/src/themes/android_theme.dart +++ b/packages/pinball_theme/lib/src/themes/android_theme.dart @@ -10,4 +10,7 @@ class AndroidTheme extends CharacterTheme { @override Color get ballColor => Colors.green; + + @override + AssetGenImage get characterAsset => Assets.images.android; } diff --git a/packages/pinball_theme/lib/src/themes/character_theme.dart b/packages/pinball_theme/lib/src/themes/character_theme.dart index 8f81486a..9478f954 100644 --- a/packages/pinball_theme/lib/src/themes/character_theme.dart +++ b/packages/pinball_theme/lib/src/themes/character_theme.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; +import 'package:pinball_theme/pinball_theme.dart'; /// {@template character_theme} /// Base class for creating character themes. @@ -14,6 +15,9 @@ abstract class CharacterTheme extends Equatable { /// Ball color for this theme. Color get ballColor; + /// Asset for the theme character. + AssetGenImage get characterAsset; + @override List get props => [ballColor]; } diff --git a/packages/pinball_theme/lib/src/themes/dash_theme.dart b/packages/pinball_theme/lib/src/themes/dash_theme.dart index e4875a11..1b5b357e 100644 --- a/packages/pinball_theme/lib/src/themes/dash_theme.dart +++ b/packages/pinball_theme/lib/src/themes/dash_theme.dart @@ -10,4 +10,7 @@ class DashTheme extends CharacterTheme { @override Color get ballColor => Colors.blue; + + @override + AssetGenImage get characterAsset => Assets.images.dash; } diff --git a/packages/pinball_theme/lib/src/themes/dino_theme.dart b/packages/pinball_theme/lib/src/themes/dino_theme.dart index 07776771..564cbea0 100644 --- a/packages/pinball_theme/lib/src/themes/dino_theme.dart +++ b/packages/pinball_theme/lib/src/themes/dino_theme.dart @@ -10,4 +10,7 @@ class DinoTheme extends CharacterTheme { @override Color get ballColor => Colors.grey; + + @override + AssetGenImage get characterAsset => Assets.images.dino; } diff --git a/packages/pinball_theme/lib/src/themes/sparky_theme.dart b/packages/pinball_theme/lib/src/themes/sparky_theme.dart index 5264bad6..b4181a8c 100644 --- a/packages/pinball_theme/lib/src/themes/sparky_theme.dart +++ b/packages/pinball_theme/lib/src/themes/sparky_theme.dart @@ -10,4 +10,7 @@ class SparkyTheme extends CharacterTheme { @override Color get ballColor => Colors.orange; + + @override + AssetGenImage get characterAsset => Assets.images.sparky; } diff --git a/packages/pinball_theme/pubspec.yaml b/packages/pinball_theme/pubspec.yaml index e9b3f215..7d745422 100644 --- a/packages/pinball_theme/pubspec.yaml +++ b/packages/pinball_theme/pubspec.yaml @@ -14,4 +14,16 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - very_good_analysis: ^2.4.0 \ No newline at end of file + very_good_analysis: ^2.4.0 + +flutter: + uses-material-design: true + generate: true + assets: + - assets/images/ + +flutter_gen: + assets: + package_parameter_enabled: true + output: lib/src/generated/ + line_length: 80 \ No newline at end of file diff --git a/packages/pinball_theme/test/src/themes/android_theme_test.dart b/packages/pinball_theme/test/src/themes/android_theme_test.dart index a6148042..24186c35 100644 --- a/packages/pinball_theme/test/src/themes/android_theme_test.dart +++ b/packages/pinball_theme/test/src/themes/android_theme_test.dart @@ -17,5 +17,9 @@ void main() { test('ballColor is correct', () { expect(AndroidTheme().ballColor, equals(Colors.green)); }); + + test('characterAsset is correct', () { + expect(AndroidTheme().characterAsset, equals(Assets.images.android)); + }); }); } diff --git a/packages/pinball_theme/test/src/themes/dash_theme_test.dart b/packages/pinball_theme/test/src/themes/dash_theme_test.dart index 0d5c8293..2fb429e0 100644 --- a/packages/pinball_theme/test/src/themes/dash_theme_test.dart +++ b/packages/pinball_theme/test/src/themes/dash_theme_test.dart @@ -17,5 +17,9 @@ void main() { test('ballColor is correct', () { expect(DashTheme().ballColor, equals(Colors.blue)); }); + + test('characterAsset is correct', () { + expect(DashTheme().characterAsset, equals(Assets.images.dash)); + }); }); } diff --git a/packages/pinball_theme/test/src/themes/dino_theme_test.dart b/packages/pinball_theme/test/src/themes/dino_theme_test.dart index 6efd8cbd..673cccf6 100644 --- a/packages/pinball_theme/test/src/themes/dino_theme_test.dart +++ b/packages/pinball_theme/test/src/themes/dino_theme_test.dart @@ -17,5 +17,9 @@ void main() { test('ballColor is correct', () { expect(DinoTheme().ballColor, equals(Colors.grey)); }); + + test('characterAsset is correct', () { + expect(DinoTheme().characterAsset, equals(Assets.images.dino)); + }); }); } diff --git a/packages/pinball_theme/test/src/themes/sparky_theme_test.dart b/packages/pinball_theme/test/src/themes/sparky_theme_test.dart index 513ca219..d0d96566 100644 --- a/packages/pinball_theme/test/src/themes/sparky_theme_test.dart +++ b/packages/pinball_theme/test/src/themes/sparky_theme_test.dart @@ -17,5 +17,9 @@ void main() { test('ballColor is correct', () { expect(SparkyTheme().ballColor, equals(Colors.orange)); }); + + test('characterAsset is correct', () { + expect(SparkyTheme().characterAsset, equals(Assets.images.sparky)); + }); }); } diff --git a/pubspec.lock b/pubspec.lock index 4b375ff7..8ff31de7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -140,21 +140,21 @@ packages: name: flame url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-releasecandidate.2" + version: "1.1.0-releasecandidate.4" flame_bloc: dependency: "direct main" description: name: flame_bloc url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-releasecandidate.2" + version: "1.2.0-releasecandidate.4" flame_forge2d: dependency: "direct main" description: name: flame_forge2d url: "https://pub.dartlang.org" source: hosted - version: "0.9.0-releasecandidate.2" + version: "0.9.0-releasecandidate.4" flame_test: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 41b0d081..39b93fe7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,15 +9,15 @@ environment: dependencies: bloc: ^8.0.2 equatable: ^2.0.3 - flame: ^1.1.0-releasecandidate.2 - flame_bloc: ^1.2.0-releasecandidate.2 - flame_forge2d: ^0.9.0-releasecandidate.2 + flame: ^1.1.0-releasecandidate.4 + flame_bloc: ^1.2.0-releasecandidate.4 + flame_forge2d: ^0.9.0-releasecandidate.4 flutter: sdk: flutter flutter_bloc: ^8.0.1 flutter_localizations: sdk: flutter - geometry: + geometry: path: packages/geometry intl: ^0.17.0 pinball_theme: diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart index bd669397..ad1d6c55 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -21,9 +21,24 @@ void main() { } }, expect: () => [ - const GameState(score: 0, balls: 2, bonusLetters: []), - const GameState(score: 0, balls: 1, bonusLetters: []), - const GameState(score: 0, balls: 0, bonusLetters: []), + const GameState( + score: 0, + balls: 2, + activatedBonusLetters: [], + bonusHistory: [], + ), + const GameState( + score: 0, + balls: 1, + activatedBonusLetters: [], + bonusHistory: [], + ), + const GameState( + score: 0, + balls: 0, + activatedBonusLetters: [], + bonusHistory: [], + ), ], ); }); @@ -37,8 +52,18 @@ void main() { ..add(const Scored(points: 2)) ..add(const Scored(points: 3)), expect: () => [ - const GameState(score: 2, balls: 3, bonusLetters: []), - const GameState(score: 5, balls: 3, bonusLetters: []), + const GameState( + score: 2, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [], + ), + const GameState( + score: 5, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [], + ), ], ); @@ -53,9 +78,24 @@ void main() { bloc.add(const Scored(points: 2)); }, expect: () => [ - const GameState(score: 0, balls: 2, bonusLetters: []), - const GameState(score: 0, balls: 1, bonusLetters: []), - const GameState(score: 0, balls: 0, bonusLetters: []), + const GameState( + score: 0, + balls: 2, + activatedBonusLetters: [], + bonusHistory: [], + ), + const GameState( + score: 0, + balls: 1, + activatedBonusLetters: [], + bonusHistory: [], + ), + const GameState( + score: 0, + balls: 0, + activatedBonusLetters: [], + bonusHistory: [], + ), ], ); }); @@ -65,42 +105,77 @@ void main() { 'adds the letter to the state', build: GameBloc.new, act: (bloc) => bloc - ..add(const BonusLetterActivated('G')) - ..add(const BonusLetterActivated('O')) - ..add(const BonusLetterActivated('O')) - ..add(const BonusLetterActivated('G')) - ..add(const BonusLetterActivated('L')) - ..add(const BonusLetterActivated('E')), - expect: () => [ - const GameState( + ..add(const BonusLetterActivated(0)) + ..add(const BonusLetterActivated(1)) + ..add(const BonusLetterActivated(2)), + expect: () => const [ + GameState( score: 0, balls: 3, - bonusLetters: ['G'], + activatedBonusLetters: [0], + bonusHistory: [], ), - const GameState( + GameState( score: 0, balls: 3, - bonusLetters: ['G', 'O'], + activatedBonusLetters: [0, 1], + bonusHistory: [], ), - const GameState( + GameState( score: 0, balls: 3, - bonusLetters: ['G', 'O', 'O'], + activatedBonusLetters: [0, 1, 2], + bonusHistory: [], ), - const GameState( + ], + ); + + blocTest( + 'adds the bonus when the bonusWord is completed', + build: GameBloc.new, + act: (bloc) => bloc + ..add(const BonusLetterActivated(0)) + ..add(const BonusLetterActivated(1)) + ..add(const BonusLetterActivated(2)) + ..add(const BonusLetterActivated(3)) + ..add(const BonusLetterActivated(4)) + ..add(const BonusLetterActivated(5)), + expect: () => const [ + GameState( score: 0, balls: 3, - bonusLetters: ['G', 'O', 'O', 'G'], + activatedBonusLetters: [0], + bonusHistory: [], ), - const GameState( + GameState( score: 0, balls: 3, - bonusLetters: ['G', 'O', 'O', 'G', 'L'], + activatedBonusLetters: [0, 1], + bonusHistory: [], ), - const GameState( + GameState( + score: 0, + balls: 3, + activatedBonusLetters: [0, 1, 2], + bonusHistory: [], + ), + GameState( + score: 0, + balls: 3, + activatedBonusLetters: [0, 1, 2, 3], + bonusHistory: [], + ), + GameState( + score: 0, + balls: 3, + activatedBonusLetters: [0, 1, 2, 3, 4], + bonusHistory: [], + ), + GameState( score: 0, balls: 3, - bonusLetters: ['G', 'O', 'O', 'G', 'L', 'E'], + activatedBonusLetters: [], + bonusHistory: [GameBonus.word], ), ], ); diff --git a/test/game/bloc/game_event_test.dart b/test/game/bloc/game_event_test.dart index 0e7a0f71..d6d2278b 100644 --- a/test/game/bloc/game_event_test.dart +++ b/test/game/bloc/game_event_test.dart @@ -43,19 +43,29 @@ void main() { group('BonusLetterActivated', () { test('can be instantiated', () { - expect(const BonusLetterActivated('A'), isNotNull); + expect(const BonusLetterActivated(0), isNotNull); }); test('supports value equality', () { expect( - BonusLetterActivated('A'), - equals(BonusLetterActivated('A')), + BonusLetterActivated(0), + equals(BonusLetterActivated(0)), ); expect( - BonusLetterActivated('B'), - isNot(equals(BonusLetterActivated('A'))), + BonusLetterActivated(0), + isNot(equals(BonusLetterActivated(1))), ); }); + + test( + 'throws assertion error if index is bigger than the word length', + () { + expect( + () => BonusLetterActivated(8), + throwsAssertionError, + ); + }, + ); }); }); } diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index 7345d3bd..8ab72e6c 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -10,13 +10,15 @@ void main() { GameState( score: 0, balls: 0, - bonusLetters: const [], + activatedBonusLetters: const [], + bonusHistory: const [], ), equals( const GameState( score: 0, balls: 0, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ), ), ); @@ -25,7 +27,12 @@ void main() { group('constructor', () { test('can be instantiated', () { expect( - const GameState(score: 0, balls: 0, bonusLetters: []), + const GameState( + score: 0, + balls: 0, + activatedBonusLetters: [], + bonusHistory: [], + ), isNotNull, ); }); @@ -36,7 +43,12 @@ void main() { 'when balls are negative', () { expect( - () => GameState(balls: -1, score: 0, bonusLetters: const []), + () => GameState( + balls: -1, + score: 0, + activatedBonusLetters: const [], + bonusHistory: const [], + ), throwsAssertionError, ); }, @@ -47,7 +59,12 @@ void main() { 'when score is negative', () { expect( - () => GameState(balls: 0, score: -1, bonusLetters: const []), + () => GameState( + balls: 0, + score: -1, + activatedBonusLetters: const [], + bonusHistory: const [], + ), throwsAssertionError, ); }, @@ -60,7 +77,8 @@ void main() { const gameState = GameState( balls: 0, score: 0, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ); expect(gameState.isGameOver, isTrue); }); @@ -71,7 +89,8 @@ void main() { const gameState = GameState( balls: 1, score: 0, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ); expect(gameState.isGameOver, isFalse); }); @@ -85,7 +104,8 @@ void main() { const gameState = GameState( balls: 1, score: 0, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ); expect(gameState.isLastBall, isTrue); }, @@ -98,13 +118,42 @@ void main() { const gameState = GameState( balls: 2, score: 0, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ); expect(gameState.isLastBall, isFalse); }, ); }); + group('isLetterActivated', () { + test( + 'is true when the letter is activated', + () { + const gameState = GameState( + balls: 3, + score: 0, + activatedBonusLetters: [1], + bonusHistory: [], + ); + expect(gameState.isLetterActivated(1), isTrue); + }, + ); + + test( + 'is false when the letter is not activated', + () { + const gameState = GameState( + balls: 3, + score: 0, + activatedBonusLetters: [1], + bonusHistory: [], + ); + expect(gameState.isLetterActivated(0), isFalse); + }, + ); + }); + group('copyWith', () { test( 'throws AssertionError ' @@ -113,7 +162,8 @@ void main() { const gameState = GameState( balls: 0, score: 2, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ); expect( () => gameState.copyWith(score: gameState.score - 1), @@ -129,7 +179,8 @@ void main() { const gameState = GameState( balls: 0, score: 2, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ); expect( gameState.copyWith(), @@ -145,12 +196,14 @@ void main() { const gameState = GameState( score: 2, balls: 0, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ); final otherGameState = GameState( score: gameState.score + 1, balls: gameState.balls + 1, - bonusLetters: const ['A'], + activatedBonusLetters: const [0], + bonusHistory: const [GameBonus.word], ); expect(gameState, isNot(equals(otherGameState))); @@ -158,7 +211,8 @@ void main() { gameState.copyWith( score: otherGameState.score, balls: otherGameState.balls, - bonusLetters: otherGameState.bonusLetters, + activatedBonusLetters: otherGameState.activatedBonusLetters, + bonusHistory: otherGameState.bonusHistory, ), equals(otherGameState), ); diff --git a/test/game/components/anchor_test.dart b/test/game/components/anchor_test.dart index 5cc37eca..49721947 100644 --- a/test/game/components/anchor_test.dart +++ b/test/game/components/anchor_test.dart @@ -5,16 +5,19 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; +import '../../helpers/helpers.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Anchor', () { - final flameTester = FlameTester(PinballGame.new); + final flameTester = FlameTester(PinballGameTest.create); flameTester.test( 'loads correctly', (game) async { final anchor = Anchor(position: Vector2.zero()); + await game.ready(); await game.ensureAdd(anchor); expect(game.contains(anchor), isTrue); @@ -25,6 +28,7 @@ void main() { flameTester.test( 'positions correctly', (game) async { + await game.ready(); final position = Vector2.all(10); final anchor = Anchor(position: position); await game.ensureAdd(anchor); @@ -37,6 +41,7 @@ void main() { flameTester.test( 'is static', (game) async { + await game.ready(); final anchor = Anchor(position: Vector2.zero()); await game.ensureAdd(anchor); diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index bd2cbcfc..8ac32212 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -13,7 +13,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Ball', () { - final flameTester = FlameTester(PinballGame.new); + final flameTester = FlameTester(PinballGameTest.create); flameTester.test( 'loads correctly', @@ -34,7 +34,11 @@ void main() { await game.ensureAdd(ball); game.contains(ball); - expect(ball.body.position, position); + final expectedPosition = Vector2( + position.x, + position.y + ball.size.y, + ); + expect(ball.body.position, equals(expectedPosition)); }, ); @@ -49,7 +53,7 @@ void main() { ); }); - group('first fixture', () { + group('fixture', () { flameTester.test( 'exists', (game) async { @@ -96,11 +100,7 @@ void main() { ); }); - final tester = flameBlocTester( - gameBlocBuilder: () { - return gameBloc; - }, - ); + final tester = flameBlocTester(gameBloc: () => gameBloc); tester.widgetTest( 'adds BallLost to GameBloc', @@ -119,7 +119,7 @@ void main() { (game, tester) async { await game.ready(); - game.children.whereType().first.removeFromParent(); + game.children.whereType().first.lost(); await game.ready(); // Making sure that all additions are done expect( @@ -138,7 +138,8 @@ void main() { initialState: const GameState( score: 10, balls: 1, - bonusLetters: [], + activatedBonusLetters: [], + bonusHistory: [], ), ); await game.ready(); diff --git a/test/game/components/baseboard_test.dart b/test/game/components/baseboard_test.dart new file mode 100644 index 00000000..51002cef --- /dev/null +++ b/test/game/components/baseboard_test.dart @@ -0,0 +1,76 @@ +// 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'; + +import '../../helpers/helpers.dart'; + +void main() { + group('Baseboard', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + flameTester.test( + 'loads correctly', + (game) async { + await game.ready(); + final leftBaseboard = Baseboard.left(position: Vector2.zero()); + final rightBaseboard = Baseboard.right(position: Vector2.zero()); + await game.ensureAddAll([leftBaseboard, rightBaseboard]); + + expect(game.contains(leftBaseboard), isTrue); + expect(game.contains(rightBaseboard), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final baseboard = Baseboard.left(position: position); + await game.ensureAdd(baseboard); + game.contains(baseboard); + + expect(baseboard.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final baseboard = Baseboard.left(position: Vector2.zero()); + await game.ensureAdd(baseboard); + + expect(baseboard.body.bodyType, equals(BodyType.static)); + }, + ); + + flameTester.test( + 'is at an angle', + (game) async { + final leftBaseboard = Baseboard.left(position: Vector2.zero()); + final rightBaseboard = Baseboard.right(position: Vector2.zero()); + await game.ensureAddAll([leftBaseboard, rightBaseboard]); + + expect(leftBaseboard.body.angle, isNegative); + expect(rightBaseboard.body.angle, isPositive); + }, + ); + }); + + group('fixtures', () { + flameTester.test( + 'has three', + (game) async { + final baseboard = Baseboard.left(position: Vector2.zero()); + await game.ensureAdd(baseboard); + + expect(baseboard.body.fixtures.length, equals(3)); + }, + ); + }); + }); +} diff --git a/test/game/components/bonus_word_test.dart b/test/game/components/bonus_word_test.dart new file mode 100644 index 00000000..129f68d1 --- /dev/null +++ b/test/game/components/bonus_word_test.dart @@ -0,0 +1,244 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('BonusWord', () { + final flameTester = FlameTester(PinballGameTest.create); + + flameTester.test( + 'loads the letters correctly', + (game) async { + await game.ready(); + + final bonusWord = game.children.whereType().first; + final letters = bonusWord.children.whereType(); + expect(letters.length, equals(GameBloc.bonusWord.length)); + }, + ); + }); + + group('BonusLetter', () { + final flameTester = FlameTester(PinballGameTest.create); + + flameTester.test( + 'loads correctly', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + await game.ready(); + + expect(game.contains(bonusLetter), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final bonusLetter = BonusLetter( + position: position, + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + game.contains(bonusLetter); + + expect(bonusLetter.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + expect(bonusLetter.body.bodyType, equals(BodyType.static)); + }, + ); + }); + + group('fixture', () { + flameTester.test( + 'exists', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + expect(bonusLetter.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'is sensor', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + final fixture = bonusLetter.body.fixtures[0]; + expect(fixture.isSensor, isTrue); + }, + ); + + flameTester.test( + 'shape is circular', + (game) async { + final bonusLetter = BonusLetter( + position: Vector2.zero(), + letter: 'G', + index: 0, + ); + await game.ensureAdd(bonusLetter); + + final fixture = bonusLetter.body.fixtures[0]; + expect(fixture.shape.shapeType, equals(ShapeType.circle)); + expect(fixture.shape.radius, equals(2)); + }, + ); + }); + + group('bonus letter activation', () { + final gameBloc = MockGameBloc(); + + BonusLetter _getBonusLetter(PinballGame game) { + return game.children + .whereType() + .first + .children + .whereType() + .first; + } + + setUp(() { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final tester = flameBlocTester(gameBloc: () => gameBloc); + + tester.widgetTest( + 'adds BonusLetterActivated to GameBloc when not activated', + (game, tester) async { + await game.ready(); + + _getBonusLetter(game).activate(); + + await tester.pump(); + + verify(() => gameBloc.add(const BonusLetterActivated(0))).called(1); + }, + ); + + tester.widgetTest( + "doesn't add BonusLetterActivated to GameBloc when already activated", + (game, tester) async { + const state = GameState( + score: 0, + balls: 2, + activatedBonusLetters: [0], + bonusHistory: [], + ); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + await game.ready(); + + _getBonusLetter(game).activate(); + await game.ready(); // Making sure that all additions are done + + verifyNever(() => gameBloc.add(const BonusLetterActivated(0))); + }, + ); + + tester.widgetTest( + 'adds a ColorEffect', + (game, tester) async { + await game.ready(); + + const state = GameState( + score: 0, + balls: 2, + activatedBonusLetters: [0], + bonusHistory: [], + ); + + final bonusLetter = _getBonusLetter(game); + + bonusLetter.onNewState(state); + await tester.pump(); + + expect( + bonusLetter.children.whereType().length, + equals(1), + ); + }, + ); + + tester.widgetTest( + 'only listens when there is a change on the letter status', + (game, tester) async { + await game.ready(); + + const state = GameState( + score: 0, + balls: 2, + activatedBonusLetters: [0], + bonusHistory: [], + ); + + final bonusLetter = _getBonusLetter(game); + + expect( + bonusLetter.listenWhen(const GameState.initial(), state), + isTrue, + ); + }, + ); + }); + + group('BonusLetterBallContactCallback', () { + test('calls ball.activate', () { + final ball = MockBall(); + final bonusLetter = MockBonusLetter(); + + final contactCallback = BonusLetterBallContactCallback(); + contactCallback.begin(ball, bonusLetter, MockContact()); + + verify(bonusLetter.activate).called(1); + }); + }); + }); +} diff --git a/test/game/components/flipper_test.dart b/test/game/components/flipper_test.dart index b9894d9a..070265bc 100644 --- a/test/game/components/flipper_test.dart +++ b/test/game/components/flipper_test.dart @@ -2,6 +2,7 @@ import 'dart:collection'; +import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/services.dart'; @@ -12,7 +13,106 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGame.new); + final flameTester = FlameTester(PinballGameTest.create); + + group('FlipperGroup', () { + flameTester.test( + 'loads correctly', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect(game.contains(flipperGroup), isTrue); + }, + ); + + group('constructor', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final flipperGroup = FlipperGroup( + position: position, + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect(flipperGroup.position, equals(position)); + }, + ); + }); + + group('children', () { + bool Function(Component) flipperSelector(BoardSide side) => + (component) => component is Flipper && component.side == side; + + flameTester.test( + 'has only one left Flipper', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect( + () => flipperGroup.children.singleWhere( + flipperSelector(BoardSide.left), + ), + returnsNormally, + ); + }, + ); + + flameTester.test( + 'has only one right Flipper', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 0, + ); + await game.ensureAdd(flipperGroup); + + expect( + () => flipperGroup.children.singleWhere( + flipperSelector(BoardSide.right), + ), + returnsNormally, + ); + }, + ); + + flameTester.test( + 'spaced correctly', + (game) async { + final flipperGroup = FlipperGroup( + position: Vector2.zero(), + spacing: 2, + ); + await game.ready(); + await game.ensureAdd(flipperGroup); + + final leftFlipper = flipperGroup.children.singleWhere( + flipperSelector(BoardSide.left), + ) as Flipper; + final rightFlipper = flipperGroup.children.singleWhere( + flipperSelector(BoardSide.right), + ) as Flipper; + + expect( + leftFlipper.body.position.x + + leftFlipper.size.x + + flipperGroup.spacing, + equals(rightFlipper.body.position.x), + ); + }, + ); + }); + }); + group( 'Flipper', () { @@ -21,9 +121,11 @@ void main() { (game) async { final leftFlipper = Flipper.left(position: Vector2.zero()); final rightFlipper = Flipper.right(position: Vector2.zero()); + await game.ready(); await game.ensureAddAll([leftFlipper, rightFlipper]); expect(game.contains(leftFlipper), isTrue); + expect(game.contains(rightFlipper), isTrue); }, ); @@ -255,36 +357,33 @@ void main() { }, ); - group( - 'FlipperAnchor', - () { - flameTester.test( - 'position is at the left of the left Flipper', - (game) async { - final flipper = Flipper.left(position: Vector2.zero()); - await game.ensureAdd(flipper); + group('FlipperAnchor', () { + flameTester.test( + 'position is at the left of the left Flipper', + (game) async { + final flipper = Flipper.left(position: Vector2.zero()); + await game.ensureAdd(flipper); - final flipperAnchor = FlipperAnchor(flipper: flipper); - await game.ensureAdd(flipperAnchor); + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); - expect(flipperAnchor.body.position.x, equals(-Flipper.width / 2)); - }, - ); + expect(flipperAnchor.body.position.x, equals(-Flipper.width / 2)); + }, + ); - flameTester.test( - 'position is at the right of the right Flipper', - (game) async { - final flipper = Flipper.right(position: Vector2.zero()); - await game.ensureAdd(flipper); + flameTester.test( + 'position is at the right of the right Flipper', + (game) async { + final flipper = Flipper.right(position: Vector2.zero()); + await game.ensureAdd(flipper); - final flipperAnchor = FlipperAnchor(flipper: flipper); - await game.ensureAdd(flipperAnchor); + final flipperAnchor = FlipperAnchor(flipper: flipper); + await game.ensureAdd(flipperAnchor); - expect(flipperAnchor.body.position.x, equals(Flipper.width / 2)); - }, - ); - }, - ); + expect(flipperAnchor.body.position.x, equals(Flipper.width / 2)); + }, + ); + }); group('FlipperAnchorRevoluteJointDef', () { group('initializes with', () { diff --git a/test/game/components/pathway_test.dart b/test/game/components/pathway_test.dart index d3b82e96..43af9b77 100644 --- a/test/game/components/pathway_test.dart +++ b/test/game/components/pathway_test.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; +import '../../helpers/helpers.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGame.new); + final flameTester = FlameTester(PinballGameTest.create); group('Pathway', () { const width = 50.0; @@ -18,6 +20,7 @@ void main() { flameTester.test( 'has transparent color by default when no color is specified', (game) async { + await game.ready(); final pathway = Pathway.straight( position: Vector2.zero(), start: Vector2(10, 10), @@ -38,6 +41,7 @@ void main() { flameTester.test( 'has a color when is specified', (game) async { + await game.ready(); const defaultColor = Colors.blue; final pathway = Pathway.straight( @@ -59,6 +63,7 @@ void main() { flameTester.test( 'loads correctly', (game) async { + await game.ready(); final pathway = Pathway.straight( position: Vector2.zero(), start: Vector2(10, 10), @@ -75,6 +80,7 @@ void main() { flameTester.test( 'positions correctly', (game) async { + await game.ready(); final position = Vector2.all(10); final pathway = Pathway.straight( position: position, @@ -92,6 +98,7 @@ void main() { flameTester.test( 'is static', (game) async { + await game.ready(); final pathway = Pathway.straight( position: Vector2.zero(), start: Vector2(10, 10), @@ -109,6 +116,7 @@ void main() { flameTester.test( 'has only one ChainShape when singleWall is true', (game) async { + await game.ready(); final pathway = Pathway.straight( position: Vector2.zero(), start: Vector2(10, 10), @@ -128,6 +136,7 @@ void main() { flameTester.test( 'has two ChainShape when singleWall is false (default)', (game) async { + await game.ready(); final pathway = Pathway.straight( position: Vector2.zero(), start: Vector2(10, 10), @@ -150,6 +159,7 @@ void main() { flameTester.test( 'loads correctly', (game) async { + await game.ready(); final pathway = Pathway.arc( position: Vector2.zero(), width: width, @@ -166,6 +176,7 @@ void main() { flameTester.test( 'positions correctly', (game) async { + await game.ready(); final position = Vector2.all(10); final pathway = Pathway.arc( position: position, @@ -183,6 +194,7 @@ void main() { flameTester.test( 'is static', (game) async { + await game.ready(); final pathway = Pathway.arc( position: Vector2.zero(), width: width, @@ -208,6 +220,7 @@ void main() { flameTester.test( 'loads correctly', (game) async { + await game.ready(); final pathway = Pathway.bezierCurve( position: Vector2.zero(), controlPoints: controlPoints, @@ -223,6 +236,7 @@ void main() { flameTester.test( 'positions correctly', (game) async { + await game.ready(); final position = Vector2.all(10); final pathway = Pathway.bezierCurve( position: position, @@ -239,6 +253,7 @@ void main() { flameTester.test( 'is static', (game) async { + await game.ready(); final pathway = Pathway.bezierCurve( position: Vector2.zero(), controlPoints: controlPoints, diff --git a/test/game/components/plunger_test.dart b/test/game/components/plunger_test.dart index 67e215fd..1ca29e85 100644 --- a/test/game/components/plunger_test.dart +++ b/test/game/components/plunger_test.dart @@ -1,8 +1,11 @@ // ignore_for_file: cascade_invocations +import 'dart:collection'; + import 'package:bloc_test/bloc_test.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_test/flame_test.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; @@ -10,13 +13,19 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGame.new); + final flameTester = FlameTester(PinballGameTest.create); group('Plunger', () { + const compressionDistance = 0.0; + flameTester.test( 'loads correctly', (game) async { - final plunger = Plunger(position: Vector2.zero()); + await game.ready(); + final plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); await game.ensureAdd(plunger); expect(game.contains(plunger), isTrue); @@ -28,7 +37,10 @@ void main() { 'positions correctly', (game) async { final position = Vector2.all(10); - final plunger = Plunger(position: position); + final plunger = Plunger( + position: position, + compressionDistance: compressionDistance, + ); await game.ensureAdd(plunger); game.contains(plunger); @@ -39,7 +51,10 @@ void main() { flameTester.test( 'is dynamic', (game) async { - final plunger = Plunger(position: Vector2.zero()); + final plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); await game.ensureAdd(plunger); expect(plunger.body.bodyType, equals(BodyType.dynamic)); @@ -49,7 +64,10 @@ void main() { flameTester.test( 'ignores gravity', (game) async { - final plunger = Plunger(position: Vector2.zero()); + final plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); await game.ensureAdd(plunger); expect(plunger.body.gravityScale, isZero); @@ -57,11 +75,14 @@ void main() { ); }); - group('first fixture', () { + group('fixture', () { flameTester.test( 'exists', (game) async { - final plunger = Plunger(position: Vector2.zero()); + final plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); await game.ensureAdd(plunger); expect(plunger.body.fixtures[0], isA()); @@ -71,121 +92,150 @@ void main() { flameTester.test( 'shape is a polygon', (game) async { - final plunger = Plunger(position: Vector2.zero()); + final plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); await game.ensureAdd(plunger); final fixture = plunger.body.fixtures[0]; expect(fixture.shape.shapeType, equals(ShapeType.polygon)); }, ); - }); - - flameTester.test( - 'pull sets a negative linear velocity', - (game) async { - final plunger = Plunger(position: Vector2.zero()); - await game.ensureAdd(plunger); - plunger.pull(); - - expect(plunger.body.linearVelocity.y, isNegative); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); - - group('release', () { flameTester.test( - 'does not set a linear velocity ' - 'when plunger is in starting position', + 'has density', (game) async { - final plunger = Plunger(position: Vector2.zero()); + final plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); await game.ensureAdd(plunger); - plunger.release(); - - expect(plunger.body.linearVelocity.y, isZero); - expect(plunger.body.linearVelocity.x, isZero); + final fixture = plunger.body.fixtures[0]; + expect(fixture.density, greaterThan(0)); }, ); + }); - flameTester.test( - 'sets a positive linear velocity ' - 'when plunger is below starting position', - (game) async { - final plunger = Plunger(position: Vector2.zero()); - await game.ensureAdd(plunger); + group('onKeyEvent', () { + final keys = UnmodifiableListView([ + LogicalKeyboardKey.space, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.keyS, + ]); - plunger.body.setTransform(Vector2(0, -1), 0); - plunger.release(); + late Plunger plunger; - expect(plunger.body.linearVelocity.y, isPositive); - expect(plunger.body.linearVelocity.x, isZero); - }, - ); + setUp(() { + plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); + }); + + testRawKeyUpEvents(keys, (event) { + final keyLabel = (event.logicalKey != LogicalKeyboardKey.space) + ? event.logicalKey.keyLabel + : 'Space'; + flameTester.test( + 'moves upwards when $keyLabel is released ' + 'and plunger is below its starting position', + (game) async { + await game.ensureAdd(plunger); + plunger.body.setTransform(Vector2(0, -1), 0); + plunger.onKeyEvent(event, {}); + + expect(plunger.body.linearVelocity.y, isPositive); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyUpEvents(keys, (event) { + final keyLabel = (event.logicalKey != LogicalKeyboardKey.space) + ? event.logicalKey.keyLabel + : 'Space'; + flameTester.test( + 'does not move when $keyLabel is released ' + 'and plunger is in its starting position', + (game) async { + await game.ensureAdd(plunger); + plunger.onKeyEvent(event, {}); + + expect(plunger.body.linearVelocity.y, isZero); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + }); + + testRawKeyDownEvents(keys, (event) { + final keyLabel = (event.logicalKey != LogicalKeyboardKey.space) + ? event.logicalKey.keyLabel + : 'Space'; + flameTester.test( + 'moves downwards when $keyLabel is pressed', + (game) async { + await game.ensureAdd(plunger); + plunger.onKeyEvent(event, {}); + + expect(plunger.body.linearVelocity.y, isNegative); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + }); }); }); + group('PlungerAnchor', () { + const compressionDistance = 10.0; + + flameTester.test( + 'position is a compression distance below the Plunger', + (game) async { + final plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); + await game.ensureAdd(plunger); + + final plungerAnchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(plungerAnchor); + + expect( + plungerAnchor.body.position.y, + equals(plunger.body.position.y - compressionDistance), + ); + }, + ); + }); + group('PlungerAnchorPrismaticJointDef', () { - late GameBloc gameBloc; + const compressionDistance = 10.0; + final gameBloc = MockGameBloc(); late Plunger plunger; - late Anchor anchor; setUp(() { - gameBloc = MockGameBloc(); whenListen( gameBloc, const Stream.empty(), initialState: const GameState.initial(), ); - plunger = Plunger(position: Vector2.zero()); - anchor = Anchor(position: Vector2(0, -1)); + plunger = Plunger( + position: Vector2.zero(), + compressionDistance: compressionDistance, + ); }); - final flameTester = flameBlocTester( - gameBlocBuilder: () { - return gameBloc; - }, - ); - - flameTester.test( - 'throws AssertionError ' - 'when anchor is above plunger', - (game) async { - final anchor = Anchor(position: Vector2(0, 1)); - await game.ensureAddAll([plunger, anchor]); - - expect( - () => PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ), - throwsAssertionError, - ); - }, - ); - - flameTester.test( - 'throws AssertionError ' - 'when anchor is in same position as plunger', - (game) async { - final anchor = Anchor(position: Vector2.zero()); - await game.ensureAddAll([plunger, anchor]); - - expect( - () => PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ), - throwsAssertionError, - ); - }, - ); + final flameTester = flameBlocTester(gameBloc: () => gameBloc); group('initializes with', () { flameTester.test( 'plunger body as bodyA', (game) async { - await game.ensureAddAll([plunger, anchor]); + await game.ensureAdd(plunger); + final anchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(anchor); final jointDef = PlungerAnchorPrismaticJointDef( plunger: plunger, @@ -199,7 +249,9 @@ void main() { flameTester.test( 'anchor body as bodyB', (game) async { - await game.ensureAddAll([plunger, anchor]); + await game.ensureAdd(plunger); + final anchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(anchor); final jointDef = PlungerAnchorPrismaticJointDef( plunger: plunger, @@ -214,7 +266,9 @@ void main() { flameTester.test( 'limits enabled', (game) async { - await game.ensureAddAll([plunger, anchor]); + await game.ensureAdd(plunger); + final anchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(anchor); final jointDef = PlungerAnchorPrismaticJointDef( plunger: plunger, @@ -229,7 +283,9 @@ void main() { flameTester.test( 'lower translation limit as negative infinity', (game) async { - await game.ensureAddAll([plunger, anchor]); + await game.ensureAdd(plunger); + final anchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(anchor); final jointDef = PlungerAnchorPrismaticJointDef( plunger: plunger, @@ -244,7 +300,9 @@ void main() { flameTester.test( 'connected body collison enabled', (game) async { - await game.ensureAddAll([plunger, anchor]); + await game.ensureAdd(plunger); + final anchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(anchor); final jointDef = PlungerAnchorPrismaticJointDef( plunger: plunger, @@ -257,46 +315,51 @@ void main() { ); }); - flameTester.widgetTest( - 'plunger cannot go below anchor', - (game, tester) async { - await game.ensureAddAll([plunger, anchor]); + testRawKeyUpEvents([LogicalKeyboardKey.space], (event) { + flameTester.widgetTest( + 'plunger cannot go below anchor', + (game, tester) async { + await game.ensureAdd(plunger); + final anchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(anchor); - // Giving anchor a shape for the plunger to collide with. - anchor.body.createFixtureFromShape(PolygonShape()..setAsBoxXY(2, 1)); + // Giving anchor a shape for the plunger to collide with. + anchor.body.createFixtureFromShape(PolygonShape()..setAsBoxXY(2, 1)); - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(jointDef); + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); - plunger.pull(); - await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); - expect(plunger.body.position.y > anchor.body.position.y, isTrue); - }, - ); + expect(plunger.body.position.y > anchor.body.position.y, isTrue); + }, + ); + }); - flameTester.widgetTest( - 'plunger cannot excessively exceed starting position', - (game, tester) async { - await game.ensureAddAll([plunger, anchor]); + testRawKeyUpEvents([LogicalKeyboardKey.space], (event) { + flameTester.widgetTest( + 'plunger cannot excessively exceed starting position', + (game, tester) async { + await game.ensureAdd(plunger); + final anchor = PlungerAnchor(plunger: plunger); + await game.ensureAdd(anchor); - final jointDef = PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: anchor, - ); - game.world.createJoint(jointDef); + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); - plunger.pull(); - await tester.pump(const Duration(seconds: 1)); + plunger.body.setTransform(Vector2(0, -1), 0); - plunger.release(); - await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); - expect(plunger.body.position.y < 1, isTrue); - }, - ); + expect(plunger.body.position.y < 1, isTrue); + }, + ); + }); }); } diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart index 8151055e..e60046ad 100644 --- a/test/game/components/wall_test.dart +++ b/test/game/components/wall_test.dart @@ -32,11 +32,12 @@ void main() { }, ); }); - final flameTester = FlameTester(PinballGame.new); + final flameTester = FlameTester(PinballGameTest.create); flameTester.test( 'loads correctly', (game) async { + await game.ready(); final wall = Wall( start: Vector2.zero(), end: Vector2(100, 0), @@ -76,7 +77,7 @@ void main() { ); }); - group('first fixture', () { + group('fixture', () { flameTester.test( 'exists', (game) async { @@ -91,7 +92,7 @@ void main() { ); flameTester.test( - 'has restitution equals 0', + 'has restitution', (game) async { final wall = Wall( start: Vector2.zero(), @@ -100,7 +101,7 @@ void main() { await game.ensureAdd(wall); final fixture = wall.body.fixtures[0]; - expect(fixture.restitution, equals(0)); + expect(fixture.restitution, greaterThan(0)); }, ); diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index 4dc93b7f..a992a471 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -3,52 +3,104 @@ import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import '../helpers/helpers.dart'; + void main() { group('PinballGame', () { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGame.new); + final flameTester = FlameTester(PinballGameTest.create); + final debugModeFlameTester = FlameTester(DebugPinballGameTest.create); // TODO(alestiago): test if [PinballGame] registers // [BallScorePointsCallback] once the following issue is resolved: // https://github.com/flame-engine/flame/issues/1416 - group( - 'components', - () { - group('Flippers', () { - bool Function(Component) flipperSelector(BoardSide side) => - (component) => component is Flipper && component.side == side; - - flameTester.test( - 'has only one left Flipper', - (game) async { - await game.ready(); - - expect( - () => game.children.singleWhere( - flipperSelector(BoardSide.left), - ), - returnsNormally, - ); - }, + group('components', () { + bool Function(Component) componentSelector() => + (component) => component is T; + + flameTester.test( + 'has three Walls', + (game) async { + await game.ready(); + final walls = game.children + .where( + (component) => component is Wall && component is! BottomWall, + ) + .toList(); + // TODO(allisonryan0002): expect 3 when launch track is added and + // temporary wall is removed. + expect(walls.length, 4); + }, + ); + + flameTester.test( + 'has only one BottomWall', + (game) async { + await game.ready(); + + expect( + () => game.children.singleWhere( + componentSelector(), + ), + returnsNormally, ); + }, + ); - flameTester.test( - 'has only one right Flipper', - (game) async { - await game.ready(); - - expect( - () => game.children.singleWhere( - flipperSelector(BoardSide.right), - ), - returnsNormally, - ); - }, + flameTester.test( + 'has only one Plunger', + (game) async { + await game.ready(); + + expect( + () => game.children.singleWhere( + (component) => component is Plunger, + ), + returnsNormally, ); - }); - }, - ); + }, + ); + + flameTester.test('has only one FlipperGroup', (game) async { + await game.ready(); + + expect( + () => game.children.singleWhere( + (component) => component is FlipperGroup, + ), + returnsNormally, + ); + }); + + flameTester.test( + 'has two Baseboards', + (game) async { + await game.ready(); + final baseboards = game.children.whereType().toList(); + expect(baseboards.length, 2); + }, + ); + }); + + debugModeFlameTester.test('adds a ball on tap up', (game) async { + await game.ready(); + + final eventPosition = MockEventPosition(); + when(() => eventPosition.game).thenReturn(Vector2.all(10)); + + final tapUpEvent = MockTapUpInfo(); + when(() => tapUpEvent.eventPosition).thenReturn(eventPosition); + + game.onTapUp(tapUpEvent); + await game.ready(); + + expect( + game.children.whereType().length, + equals(1), + ); + }); }); } diff --git a/test/game/view/game_hud_test.dart b/test/game/view/game_hud_test.dart index e7334e41..536edbad 100644 --- a/test/game/view/game_hud_test.dart +++ b/test/game/view/game_hud_test.dart @@ -9,7 +9,12 @@ import '../../helpers/helpers.dart'; void main() { group('GameHud', () { late GameBloc gameBloc; - const initialState = GameState(score: 10, balls: 2, bonusLetters: []); + const initialState = GameState( + score: 10, + balls: 2, + activatedBonusLetters: [], + bonusHistory: [], + ); void _mockState(GameState state) { whenListen( diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 746dc2c7..dcf0c001 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -1,12 +1,17 @@ +// ignore_for_file: prefer_const_constructors + import 'package:bloc_test/bloc_test.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball_theme/pinball_theme.dart'; import '../../helpers/helpers.dart'; void main() { + const theme = PinballTheme(characterTheme: DashTheme()); + group('PinballGamePage', () { testWidgets('renders PinballGameView', (tester) async { final gameBloc = MockGameBloc(); @@ -16,7 +21,10 @@ void main() { initialState: const GameState.initial(), ); - await tester.pumpApp(const PinballGamePage(), gameBloc: gameBloc); + await tester.pumpApp( + PinballGamePage(theme: theme), + gameBloc: gameBloc, + ); expect(find.byType(PinballGameView), findsOneWidget); }); @@ -27,7 +35,8 @@ void main() { builder: (context) { return ElevatedButton( onPressed: () { - Navigator.of(context).push(PinballGamePage.route()); + Navigator.of(context) + .push(PinballGamePage.route(theme: theme)); }, child: const Text('Tap me'), ); @@ -56,7 +65,11 @@ void main() { initialState: const GameState.initial(), ); - await tester.pumpApp(const PinballGameView(), gameBloc: gameBloc); + await tester.pumpApp( + PinballGameView(theme: theme), + gameBloc: gameBloc, + ); + expect( find.byWidgetPredicate((w) => w is GameWidget), findsOneWidget, @@ -71,14 +84,23 @@ void main() { 'renders a game over dialog when the user has lost', (tester) async { final gameBloc = MockGameBloc(); - const state = GameState(score: 0, balls: 0, bonusLetters: []); + const state = GameState( + score: 0, + balls: 0, + activatedBonusLetters: [], + bonusHistory: [], + ); + whenListen( gameBloc, Stream.value(state), - initialState: state, + initialState: GameState.initial(), ); - await tester.pumpApp(const PinballGameView(), gameBloc: gameBloc); + await tester.pumpApp( + const PinballGameView(theme: theme), + gameBloc: gameBloc, + ); await tester.pump(); expect( @@ -87,5 +109,45 @@ void main() { ); }, ); + + testWidgets('renders the real game when not in debug mode', (tester) async { + final gameBloc = MockGameBloc(); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); + + await tester.pumpApp( + const PinballGameView(theme: theme, isDebugMode: false), + gameBloc: gameBloc, + ); + expect( + find.byWidgetPredicate( + (w) => w is GameWidget && w.game is! DebugPinballGame, + ), + findsOneWidget, + ); + }); + + testWidgets('renders the debug game when on debug mode', (tester) async { + final gameBloc = MockGameBloc(); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); + + await tester.pumpApp( + const PinballGameView(theme: theme), + gameBloc: gameBloc, + ); + expect( + find.byWidgetPredicate( + (w) => w is GameWidget && w.game is DebugPinballGame, + ), + findsOneWidget, + ); + }); }); } diff --git a/test/helpers/builders.dart b/test/helpers/builders.dart index e124052e..d8ffd715 100644 --- a/test/helpers/builders.dart +++ b/test/helpers/builders.dart @@ -2,15 +2,17 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; +import 'helpers.dart'; + FlameTester flameBlocTester({ - required GameBloc Function() gameBlocBuilder, + required GameBloc Function() gameBloc, }) { return FlameTester( - PinballGame.new, + PinballGameTest.create, pumpWidget: (gameWidget, tester) async { await tester.pumpWidget( BlocProvider.value( - value: gameBlocBuilder(), + value: gameBloc(), child: gameWidget, ), ); diff --git a/test/helpers/extensions.dart b/test/helpers/extensions.dart new file mode 100644 index 00000000..2a0a7e59 --- /dev/null +++ b/test/helpers/extensions.dart @@ -0,0 +1,22 @@ +import 'package:pinball/game/game.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// [PinballGame] extension to reduce boilerplate in tests. +extension PinballGameTest on PinballGame { + /// Create [PinballGame] with default [PinballTheme]. + static PinballGame create() => PinballGame( + theme: const PinballTheme( + characterTheme: DashTheme(), + ), + ); +} + +/// [DebugPinballGame] extension to reduce boilerplate in tests. +extension DebugPinballGameTest on DebugPinballGame { + /// Create [PinballGame] with default [PinballTheme]. + static DebugPinballGame create() => DebugPinballGame( + theme: const PinballTheme( + characterTheme: DashTheme(), + ), + ); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index c2c1cd36..88b9c04d 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -1,11 +1,11 @@ -// Copyright (c) 2021, Very Good Ventures -// https://verygood.ventures // +// Copyright (c) 2021, Very Good Ventures // Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. - +// https://verygood.ventures +// license that can be found in the LICENSE file or at export 'builders.dart'; +export 'extensions.dart'; export 'key_testers.dart'; export 'mocks.dart'; export 'pump_app.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index da9fd537..c1c59377 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -1,8 +1,10 @@ +import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/theme/theme.dart'; class MockPinballGame extends Mock implements PinballGame {} @@ -16,6 +18,8 @@ class MockContact extends Mock implements Contact {} class MockGameBloc extends Mock implements GameBloc {} +class MockThemeCubit extends Mock implements ThemeCubit {} + class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { @@ -29,3 +33,9 @@ class MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { return super.toString(); } } + +class MockTapUpInfo extends Mock implements TapUpInfo {} + +class MockEventPosition extends Mock implements EventPosition {} + +class MockBonusLetter extends Mock implements BonusLetter {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index 2c1efd9f..e0b953d2 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/theme/theme.dart'; import 'helpers.dart'; @@ -20,17 +21,25 @@ extension PumpApp on WidgetTester { Widget widget, { MockNavigator? navigator, GameBloc? gameBloc, + ThemeCubit? themeCubit, }) { return pumpWidget( - MaterialApp( - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, + MultiBlocProvider( + providers: [ + BlocProvider.value( + value: themeCubit ?? MockThemeCubit(), + ), + BlocProvider.value( + value: gameBloc ?? MockGameBloc(), + ), ], - supportedLocales: AppLocalizations.supportedLocales, - home: BlocProvider.value( - value: gameBloc ?? MockGameBloc(), - child: navigator != null + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: navigator != null ? MockNavigatorProvider(navigator: navigator, child: widget) : widget, ), diff --git a/test/landing/view/landing_page_test.dart b/test/landing/view/landing_page_test.dart index d754864c..ab036f9c 100644 --- a/test/landing/view/landing_page_test.dart +++ b/test/landing/view/landing_page_test.dart @@ -12,7 +12,7 @@ void main() { expect(find.byType(TextButton), findsOneWidget); }); - testWidgets('tapping on TextButton navigates to PinballGamePage', + testWidgets('tapping on TextButton navigates to CharacterSelectionPage', (tester) async { final navigator = MockNavigator(); when(() => navigator.push(any())).thenAnswer((_) async {}); diff --git a/test/theme/view/character_selection_page_test.dart b/test/theme/view/character_selection_page_test.dart new file mode 100644 index 00000000..eeac690f --- /dev/null +++ b/test/theme/view/character_selection_page_test.dart @@ -0,0 +1,110 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/theme/theme.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + late ThemeCubit themeCubit; + + setUp(() { + themeCubit = MockThemeCubit(); + whenListen( + themeCubit, + const Stream.empty(), + initialState: const ThemeState.initial(), + ); + }); + + group('CharacterSelectionPage', () { + testWidgets('renders CharacterSelectionView', (tester) async { + await tester.pumpApp( + CharacterSelectionPage(), + themeCubit: themeCubit, + ); + expect(find.byType(CharacterSelectionView), findsOneWidget); + }); + + testWidgets('route returns a valid navigation route', (tester) async { + await tester.pumpApp( + Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context) + .push(CharacterSelectionPage.route()); + }, + child: Text('Tap me'), + ); + }, + ), + ), + themeCubit: themeCubit, + ); + + await tester.tap(find.text('Tap me')); + await tester.pumpAndSettle(); + + expect(find.byType(CharacterSelectionPage), findsOneWidget); + }); + }); + + group('CharacterSelectionView', () { + testWidgets('renders correctly', (tester) async { + const titleText = 'Choose your character!'; + await tester.pumpApp( + CharacterSelectionView(), + themeCubit: themeCubit, + ); + + expect(find.text(titleText), findsOneWidget); + expect(find.byType(CharacterImageButton), findsNWidgets(4)); + expect(find.byType(TextButton), findsOneWidget); + }); + + testWidgets('calls characterSelected when a character image is tapped', + (tester) async { + const sparkyButtonKey = Key('characterSelectionPage_sparkyButton'); + + await tester.pumpApp( + CharacterSelectionView(), + themeCubit: themeCubit, + ); + + await tester.tap(find.byKey(sparkyButtonKey)); + + verify(() => themeCubit.characterSelected(SparkyTheme())).called(1); + }); + + testWidgets('navigates to PinballGamePage when start is tapped', + (tester) async { + final navigator = MockNavigator(); + when(() => navigator.push(any())).thenAnswer((_) async {}); + + await tester.pumpApp( + CharacterSelectionView(), + themeCubit: themeCubit, + navigator: navigator, + ); + await tester.ensureVisible(find.byType(TextButton)); + await tester.tap(find.byType(TextButton)); + + verify(() => navigator.push(any())).called(1); + }); + }); + + testWidgets('CharacterImageButton renders correctly', (tester) async { + await tester.pumpApp( + CharacterImageButton(DashTheme()), + themeCubit: themeCubit, + ); + + expect(find.byType(Image), findsOneWidget); + }); +}