diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index 74685215..663fee35 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -15,6 +15,7 @@ class GameBloc extends Bloc { } static const bonusWord = 'GOOGLE'; + static const bonusWordScore = 10000; void _onBallLost(BallLost event, Emitter emit) { if (state.balls > 0) { @@ -44,6 +45,7 @@ class GameBloc extends Bloc { ], ), ); + add(const Scored(points: bonusWordScore)); } else { emit( state.copyWith(activatedBonusLetters: newBonusLetters), diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index 2812a049..e2c39d1f 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -49,6 +49,10 @@ 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, diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index d5b05fa1..738ceec6 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -6,16 +6,18 @@ import 'package:pinball/game/game.dart'; /// A solid, [BodyType.dynamic] sphere that rolls and bounces along the /// [PinballGame]. /// {@endtemplate} -class Ball extends PositionBodyComponent { +class Ball extends BodyComponent { /// {@macro ball} Ball({ required Vector2 position, - }) : _position = position, - super(size: Vector2.all(2)); + }) : _position = position; /// The initial position of the [Ball] body. final Vector2 _position; + /// The size of the [Ball] + final Vector2 size = Vector2.all(2); + /// Asset location of the sprite that renders with the [Ball]. /// /// Sprite is preloaded by [PinballGameAssetsX]. @@ -26,7 +28,13 @@ class Ball extends PositionBodyComponent { await super.onLoad(); final sprite = await gameRef.loadSprite(spritePath); final tint = gameRef.theme.characterTheme.ballColor.withOpacity(0.5); - positionComponent = SpriteComponent(sprite: sprite, size: size)..tint(tint); + await add( + SpriteComponent( + sprite: sprite, + size: size, + anchor: Anchor.center, + )..tint(tint), + ); } @override 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..49a1da1d --- /dev/null +++ b/lib/game/components/bonus_word.dart @@ -0,0 +1,181 @@ +// 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 with BlocComponent { + /// {@macro bonus_word} + BonusWord({required Vector2 position}) : _position = position; + + final Vector2 _position; + + @override + bool listenWhen(GameState? previousState, GameState newState) { + if ((previousState?.bonusHistory.length ?? 0) < + newState.bonusHistory.length && + newState.bonusHistory.last == GameBonus.word) { + return true; + } + + return false; + } + + @override + void onNewState(GameState state) { + if (state.bonusHistory.last == GameBonus.word) { + final letters = children.whereType().toList(); + + for (var i = 0; i < letters.length; i++) { + final letter = letters[i]; + letter.add( + SequenceEffect( + [ + ColorEffect( + i.isOdd ? BonusLetter._activeColor : BonusLetter._disableColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ColorEffect( + i.isOdd ? BonusLetter._disableColor : BonusLetter._activeColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ], + repeatCount: 4, + )..onFinishCallback = () { + letter.add( + ColorEffect( + BonusLetter._disableColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ); + }, + ); + } + } + } + + @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 5d7b8841..2ba7b8d4 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,7 +1,9 @@ -export 'anchor.dart'; export 'ball.dart'; +export 'baseboard.dart'; export 'board_side.dart'; +export 'bonus_word.dart'; export 'flipper.dart'; +export 'joint_anchor.dart'; export 'pathway.dart'; export 'plunger.dart'; export 'score_points.dart'; diff --git a/lib/game/components/flipper.dart b/lib/game/components/flipper.dart index 6c184383..9035daaf 100644 --- a/lib/game/components/flipper.dart +++ b/lib/game/components/flipper.dart @@ -1,25 +1,29 @@ import 'dart:async'; import 'dart:math' as math; -import 'package:flame/components.dart' show PositionComponent, SpriteComponent; +import 'package:flame/components.dart'; 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 { +class FlipperGroup extends Component { /// {@macro flipper_group} FlipperGroup({ - required Vector2 position, + required this.position, required this.spacing, - }) : super(position: position); + }); /// The amount of space between the [Flipper.right] and [Flipper.left]. final double spacing; + /// The position of this [FlipperGroup] + final Vector2 position; + @override Future onLoad() async { final leftFlipper = Flipper.left( @@ -45,15 +49,14 @@ class FlipperGroup extends PositionComponent { /// /// [Flipper] can be controlled by the player in an arc motion. /// {@endtemplate flipper} -class Flipper extends PositionBodyComponent with KeyboardHandler { +class Flipper extends BodyComponent with KeyboardHandler { /// {@macro flipper} Flipper._({ required Vector2 position, required this.side, required List keys, }) : _position = position, - _keys = keys, - super(size: Vector2(width, height)); + _keys = keys; /// A left positioned [Flipper]. Flipper.left({ @@ -124,14 +127,17 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { /// Loads the sprite that renders with the [Flipper]. Future _loadSprite() async { final sprite = await gameRef.loadSprite(spritePath); - positionComponent = SpriteComponent( + final spriteComponent = SpriteComponent( sprite: sprite, - size: size, + size: Vector2(width, height), + anchor: Anchor.center, ); if (side.isRight) { - positionComponent!.flipHorizontally(); + spriteComponent.flipHorizontally(); } + + await add(spriteComponent); } /// Anchors the [Flipper] to the [RevoluteJoint] that controls its arc motion. @@ -160,11 +166,11 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { final fixtures = []; final isLeft = side.isLeft; - final bigCircleShape = CircleShape()..radius = size.y / 2; + final bigCircleShape = CircleShape()..radius = height / 2; bigCircleShape.position.setValues( isLeft - ? -(size.x / 2) + bigCircleShape.radius - : (size.x / 2) - bigCircleShape.radius, + ? -(width / 2) + bigCircleShape.radius + : (width / 2) - bigCircleShape.radius, 0, ); final bigCircleFixtureDef = FixtureDef(bigCircleShape); @@ -173,8 +179,8 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { final smallCircleShape = CircleShape()..radius = bigCircleShape.radius / 2; smallCircleShape.position.setValues( isLeft - ? (size.x / 2) - smallCircleShape.radius - : -(size.x / 2) + smallCircleShape.radius, + ? (width / 2) - smallCircleShape.radius + : -(width / 2) + smallCircleShape.radius, 0, ); final smallCircleFixtureDef = FixtureDef(smallCircleShape); @@ -205,6 +211,7 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { @override Future onLoad() async { await super.onLoad(); + paint = Paint()..color = Colors.transparent; await Future.wait([ _loadSprite(), _anchorToJoint(), @@ -249,15 +256,15 @@ class Flipper extends PositionBodyComponent with KeyboardHandler { /// /// The end of a [Flipper] depends on its [Flipper.side]. /// {@endtemplate} -class FlipperAnchor extends Anchor { +class FlipperAnchor extends JointAnchor { /// {@macro flipper_anchor} FlipperAnchor({ required Flipper flipper, }) : super( position: Vector2( flipper.side.isLeft - ? flipper.body.position.x - flipper.size.x / 2 - : flipper.body.position.x + flipper.size.x / 2, + ? flipper.body.position.x - Flipper.width / 2 + : flipper.body.position.x + Flipper.width / 2, flipper.body.position.y, ), ); diff --git a/lib/game/components/anchor.dart b/lib/game/components/joint_anchor.dart similarity index 85% rename from lib/game/components/anchor.dart rename to lib/game/components/joint_anchor.dart index 0e78aa1c..05e62b73 100644 --- a/lib/game/components/anchor.dart +++ b/lib/game/components/joint_anchor.dart @@ -1,6 +1,6 @@ import 'package:flame_forge2d/flame_forge2d.dart'; -/// {@template anchor} +/// {@template joint_anchor} /// Non visual [BodyComponent] used to hold a [BodyType.dynamic] in [Joint]s /// with this [BodyType.static]. /// @@ -15,9 +15,9 @@ import 'package:flame_forge2d/flame_forge2d.dart'; /// ); /// ``` /// {@endtemplate} -class Anchor extends BodyComponent { - /// {@macro anchor} - Anchor({ +class JointAnchor extends BodyComponent { + /// {@macro joint_anchor} + JointAnchor({ required Vector2 position, }) : _position = position; diff --git a/lib/game/components/plunger.dart b/lib/game/components/plunger.dart index 2ec2f599..9bcde451 100644 --- a/lib/game/components/plunger.dart +++ b/lib/game/components/plunger.dart @@ -1,7 +1,7 @@ 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; +import 'package:pinball/game/game.dart'; /// {@template plunger} /// [Plunger] serves as a spring, that shoots the ball on the right side of the @@ -74,12 +74,31 @@ class Plunger extends BodyComponent with KeyboardHandler { return true; } + + /// Anchors the [Plunger] to the [PrismaticJoint] that controls its vertical + /// motion. + Future _anchorToJoint() async { + final anchor = PlungerAnchor(plunger: this); + await add(anchor); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: this, + anchor: anchor, + ); + world.createJoint(jointDef); + } + + @override + Future onLoad() async { + await super.onLoad(); + await _anchorToJoint(); + } } /// {@template plunger_anchor} -/// [Anchor] positioned below a [Plunger]. +/// [JointAnchor] positioned below a [Plunger]. /// {@endtemplate} -class PlungerAnchor extends Anchor { +class PlungerAnchor extends JointAnchor { /// {@macro plunger_anchor} PlungerAnchor({ required Plunger plunger, @@ -92,11 +111,11 @@ class PlungerAnchor extends Anchor { } /// {@template plunger_anchor_prismatic_joint_def} -/// [PrismaticJointDef] between a [Plunger] and an [Anchor] with motion on +/// [PrismaticJointDef] between a [Plunger] and an [JointAnchor] with motion on /// the vertical axis. /// /// The [Plunger] is constrained vertically between its starting position and -/// the [Anchor]. The [Anchor] must be below the [Plunger]. +/// the [JointAnchor]. The [JointAnchor] must be below the [Plunger]. /// {@endtemplate} class PlungerAnchorPrismaticJointDef extends PrismaticJointDef { /// {@macro plunger_anchor_prismatic_joint_def} diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index fbb68e63..b67a8dad 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -48,6 +48,25 @@ class PinballGame extends Forge2DGame ), ); + 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 { final flippersPosition = screenToWorld( Vector2( camera.viewport.effectiveSize.x / 2, @@ -63,6 +82,7 @@ class PinballGame extends Forge2DGame ), ), ); + unawaited(_addBaseboards()); } void spawnBall() { @@ -72,6 +92,7 @@ class PinballGame extends Forge2DGame void _addContactCallbacks() { addContactCallback(BallScorePointsCallback()); addContactCallback(BottomWallBallContactCallback()); + addContactCallback(BonusLetterBallContactCallback()); } Future _addGameBoundaries() async { @@ -80,7 +101,6 @@ class PinballGame extends Forge2DGame } Future _addPlunger() async { - late PlungerAnchor plungerAnchor; final compressionDistance = camera.viewport.effectiveSize.y / 12; await add( @@ -94,14 +114,31 @@ class PinballGame extends Forge2DGame compressionDistance: compressionDistance, ), ); - await add(plungerAnchor = PlungerAnchor(plunger: plunger)); + } - world.createJoint( - PlungerAnchorPrismaticJointDef( - plunger: plunger, - anchor: plungerAnchor, + 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, + ), + ), + ); + await add(leftBaseboard); + + final rightBaseboard = Baseboard.right( + position: screenToWorld( + Vector2( + camera.viewport.effectiveSize.x / 2 + (spaceBetweenBaseboards / 2), + baseboardY, + ), ), ); + await add(rightBaseboard); } } diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 4af2168e..21bd4074 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -63,6 +63,8 @@ class _PinballGameViewState extends State { @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/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index f12ccf7d..a118501e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4,6 +4,18 @@ "@play": { "description": "Text displayed on the landing page play button" }, + "howToPlay": "How to Play", + "@howToPlay": { + "description": "Text displayed on the landing page how to play button" + }, + "launchControls": "Launch Controls", + "@launchControls": { + "description": "Text displayed on the how to play dialog with the launch controls" + }, + "flipperControls": "Flipper Controls", + "@flipperControls": { + "description": "Text displayed on the how to play dialog with the flipper controls" + }, "start": "Start", "@start": { "description": "Text displayed on the character selection page start button" diff --git a/lib/landing/view/landing_page.dart b/lib/landing/view/landing_page.dart index 38951da6..5b0474b6 100644 --- a/lib/landing/view/landing_page.dart +++ b/lib/landing/view/landing_page.dart @@ -13,12 +13,182 @@ class LandingPage extends StatelessWidget { return Scaffold( body: Center( - child: TextButton( - onPressed: () => - Navigator.of(context).push(CharacterSelectionPage.route()), - child: Text(l10n.play), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () => Navigator.of(context).push( + CharacterSelectionPage.route(), + ), + child: Text(l10n.play), + ), + TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => const _HowToPlayDialog(), + ), + child: Text(l10n.howToPlay), + ), + ], ), ), ); } } + +class _HowToPlayDialog extends StatelessWidget { + const _HowToPlayDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + const spacing = SizedBox(height: 16); + + return Dialog( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.howToPlay), + spacing, + const _LaunchControls(), + spacing, + const _FlipperControls(), + ], + ), + ), + ); + } +} + +class _LaunchControls extends StatelessWidget { + const _LaunchControls({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + const spacing = SizedBox(width: 10); + + return Column( + children: [ + Text(l10n.launchControls), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_down), + spacing, + KeyIndicator.fromKeyName(keyName: 'SPACE'), + spacing, + KeyIndicator.fromKeyName(keyName: 'S'), + ], + ) + ], + ); + } +} + +class _FlipperControls extends StatelessWidget { + const _FlipperControls({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + const rowSpacing = SizedBox(width: 20); + + return Column( + children: [ + Text(l10n.flipperControls), + const SizedBox(height: 10), + Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_left), + rowSpacing, + KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_right), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + KeyIndicator.fromKeyName(keyName: 'A'), + rowSpacing, + KeyIndicator.fromKeyName(keyName: 'D'), + ], + ) + ], + ) + ], + ); + } +} + +// TODO(allisonryan0002): remove visibility when adding final UI. +@visibleForTesting +class KeyIndicator extends StatelessWidget { + const KeyIndicator._({ + Key? key, + required String keyName, + required IconData keyIcon, + required bool fromIcon, + }) : _keyName = keyName, + _keyIcon = keyIcon, + _fromIcon = fromIcon, + super(key: key); + + const KeyIndicator.fromKeyName({Key? key, required String keyName}) + : this._( + key: key, + keyName: keyName, + keyIcon: Icons.keyboard_arrow_down, + fromIcon: false, + ); + + const KeyIndicator.fromIcon({Key? key, required IconData keyIcon}) + : this._( + key: key, + keyName: '', + keyIcon: keyIcon, + fromIcon: true, + ); + + final String _keyName; + + final IconData _keyIcon; + + final bool _fromIcon; + + @override + Widget build(BuildContext context) { + const iconPadding = EdgeInsets.all(15); + const textPadding = EdgeInsets.symmetric(vertical: 20, horizontal: 22); + final boarderColor = Colors.blue.withOpacity(0.5); + final color = Colors.blue.withOpacity(0.7); + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: boarderColor, + width: 3, + ), + ), + child: _fromIcon + ? Padding( + padding: iconPadding, + child: Icon(_keyIcon, color: color), + ) + : Padding( + padding: textPadding, + child: Text(_keyName, style: TextStyle(color: color)), + ), + ); + } +} diff --git a/packages/geometry/README.md b/packages/geometry/README.md index f0841d82..ad11d280 100644 --- a/packages/geometry/README.md +++ b/packages/geometry/README.md @@ -3,7 +3,7 @@ [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] [![License: MIT][license_badge]][license_link] -Helper package to calculate points of lines, arcs and curves for the pathways of the ball. +Provides a set of helpers for working with 2D geometry. [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg [license_link]: https://opensource.org/licenses/MIT diff --git a/packages/geometry/lib/src/geometry.dart b/packages/geometry/lib/src/geometry.dart index 6ada92e0..8574bc73 100644 --- a/packages/geometry/lib/src/geometry.dart +++ b/packages/geometry/lib/src/geometry.dart @@ -106,3 +106,12 @@ num factorial(num n) { return n * factorial(n - 1); } } + +/// Arithmetic mean position of all the [Vector2]s in a polygon. +/// +/// For more information read: https://en.wikipedia.org/wiki/Centroid +Vector2 centroid(List vertices) { + assert(vertices.isNotEmpty, 'Vertices must not be empty'); + final sum = vertices.reduce((a, b) => a + b); + return sum / vertices.length.toDouble(); +} diff --git a/packages/geometry/pubspec.yaml b/packages/geometry/pubspec.yaml index 8fffb8b3..da305129 100644 --- a/packages/geometry/pubspec.yaml +++ b/packages/geometry/pubspec.yaml @@ -1,5 +1,5 @@ name: geometry -description: Helper package to calculate points of lines, arcs and curves for the pathways of the ball +description: Provides a set of helpers for working with 2D geometry. version: 1.0.0+1 publish_to: none diff --git a/packages/geometry/test/src/geometry_test.dart b/packages/geometry/test/src/geometry_test.dart index 2a5f9169..5c33d70f 100644 --- a/packages/geometry/test/src/geometry_test.dart +++ b/packages/geometry/test/src/geometry_test.dart @@ -166,4 +166,29 @@ void main() { }); }); }); + + group('centroid', () { + test('throws AssertionError when vertices are empty', () { + expect(() => centroid([]), throwsA(isA())); + }); + + test('is correct when one vertex is given', () { + expect(centroid([Vector2.zero()]), Vector2.zero()); + }); + + test('is correct when two vertex are given', () { + expect(centroid([Vector2.zero(), Vector2(1, 1)]), Vector2(0.5, 0.5)); + }); + + test('is correct when three vertex are given', () { + expect( + centroid([ + Vector2.zero(), + Vector2(1, 1), + Vector2(2, 2), + ]), + Vector2(1, 1), + ); + }); + }); } diff --git a/pubspec.lock b/pubspec.lock index 4b375ff7..73d0fc8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "30.0.0" + version: "31.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "2.7.0" + version: "2.8.0" args: dependency: transitive description: @@ -35,14 +35,14 @@ packages: name: bloc url: "https://pub.dartlang.org" source: hosted - version: "8.0.2" + version: "8.0.3" bloc_test: dependency: "direct dev" description: name: bloc_test url: "https://pub.dartlang.org" source: hosted - version: "9.0.2" + version: "9.0.3" boolean_selector: dependency: transitive description: @@ -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.5" 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.5" 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.5" flame_test: dependency: "direct dev" description: @@ -190,7 +190,7 @@ packages: name: forge2d url: "https://pub.dartlang.org" source: hosted - version: "0.8.1" + version: "0.8.2" frontend_server_client: dependency: transitive description: @@ -218,7 +218,7 @@ packages: name: http_multi_server url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.2.0" http_parser: dependency: transitive description: @@ -246,7 +246,7 @@ packages: name: js url: "https://pub.dartlang.org" source: hosted - version: "0.6.3" + version: "0.6.4" logging: dependency: transitive description: @@ -351,14 +351,14 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.1" + version: "6.0.2" pub_semver: dependency: transitive description: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" shelf: dependency: transitive description: @@ -489,7 +489,7 @@ packages: name: vm_service url: "https://pub.dartlang.org" source: hosted - version: "7.3.0" + version: "7.5.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 41b0d081..18905a10 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.5 + flame_bloc: ^1.2.0-releasecandidate.5 + flame_forge2d: ^0.9.0-releasecandidate.5 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 ad1d6c55..18e50858 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -177,6 +177,12 @@ void main() { activatedBonusLetters: [], bonusHistory: [GameBonus.word], ), + GameState( + score: GameBloc.bonusWordScore, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [GameBonus.word], + ), ], ); }); diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index 7b060984..8ab72e6c 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -126,6 +126,34 @@ void main() { ); }); + 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 ' diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index 9366cc32..e6172d6d 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -11,10 +11,9 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); group('Ball', () { - final flameTester = FlameTester(PinballGameTest.create); - flameTester.test( 'loads correctly', (game) async { @@ -90,9 +89,10 @@ void main() { }); group('resetting a ball', () { - final gameBloc = MockGameBloc(); + late GameBloc gameBloc; setUp(() { + gameBloc = MockGameBloc(); whenListen( gameBloc, const Stream.empty(), @@ -100,7 +100,7 @@ void main() { ); }); - final tester = flameBlocTester(gameBloc: 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( diff --git a/test/game/components/baseboard_test.dart b/test/game/components/baseboard_test.dart new file mode 100644 index 00000000..bc9f68af --- /dev/null +++ b/test/game/components/baseboard_test.dart @@ -0,0 +1,74 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + group('Baseboard', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(Forge2DGame.new); + + 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..b45bd61a --- /dev/null +++ b/test/game/components/bonus_word_test.dart @@ -0,0 +1,332 @@ +// 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(); + final flameTester = FlameTester(PinballGameTest.create); + + group('BonusWord', () { + 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('listenWhen', () { + final previousState = MockGameState(); + final currentState = MockGameState(); + test( + 'returns true when there is a new word bonus awarded', + () { + when(() => previousState.bonusHistory).thenReturn([]); + when(() => currentState.bonusHistory).thenReturn([GameBonus.word]); + + expect( + BonusWord(position: Vector2.zero()).listenWhen( + previousState, + currentState, + ), + isTrue, + ); + }, + ); + + test( + 'returns false when there is no new word bonus awarded', + () { + when(() => previousState.bonusHistory).thenReturn([GameBonus.word]); + when(() => currentState.bonusHistory).thenReturn([GameBonus.word]); + + expect( + BonusWord(position: Vector2.zero()).listenWhen( + previousState, + currentState, + ), + isFalse, + ); + }, + ); + }); + + group('onNewState', () { + final state = MockGameState(); + flameTester.test( + 'adds sequence effect to the letters when the player receives a bonus', + (game) async { + when(() => state.bonusHistory).thenReturn([GameBonus.word]); + + final bonusWord = BonusWord(position: Vector2.zero()); + await game.ensureAdd(bonusWord); + await game.ready(); + + bonusWord.onNewState(state); + game.update(0); // Run one frame so the effects are added + + final letters = bonusWord.children.whereType(); + expect(letters.length, equals(GameBloc.bonusWord.length)); + + for (final letter in letters) { + expect( + letter.children.whereType().length, + equals(1), + ); + } + }, + ); + + flameTester.test( + 'adds a color effect to reset the color when the sequence is finished', + (game) async { + when(() => state.bonusHistory).thenReturn([GameBonus.word]); + + final bonusWord = BonusWord(position: Vector2.zero()); + await game.ensureAdd(bonusWord); + await game.ready(); + + bonusWord.onNewState(state); + // Run the amount of time necessary for the animation to finish + game.update(3); + game.update(0); // Run one additional frame so the effects are added + + final letters = bonusWord.children.whereType(); + expect(letters.length, equals(GameBloc.bonusWord.length)); + + for (final letter in letters) { + expect( + letter.children.whereType().length, + equals(1), + ); + } + }, + ); + }); + }); + + 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 070265bc..0e281d07 100644 --- a/test/game/components/flipper_test.dart +++ b/test/game/components/flipper_test.dart @@ -103,9 +103,7 @@ void main() { ) as Flipper; expect( - leftFlipper.body.position.x + - leftFlipper.size.x + - flipperGroup.spacing, + leftFlipper.body.position.x + Flipper.width + flipperGroup.spacing, equals(rightFlipper.body.position.x), ); }, @@ -178,6 +176,7 @@ void main() { final flipper = Flipper.left(position: Vector2.zero()); final ball = Ball(position: Vector2.zero()); + await game.ready(); await game.ensureAddAll([flipper, ball]); expect( diff --git a/test/game/components/anchor_test.dart b/test/game/components/joint_anchor_test.dart similarity index 78% rename from test/game/components/anchor_test.dart rename to test/game/components/joint_anchor_test.dart index 49721947..8278d43a 100644 --- a/test/game/components/anchor_test.dart +++ b/test/game/components/joint_anchor_test.dart @@ -5,18 +5,15 @@ 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(); + final flameTester = FlameTester(Forge2DGame.new); - group('Anchor', () { - final flameTester = FlameTester(PinballGameTest.create); - + group('JointAnchor', () { flameTester.test( 'loads correctly', (game) async { - final anchor = Anchor(position: Vector2.zero()); + final anchor = JointAnchor(position: Vector2.zero()); await game.ready(); await game.ensureAdd(anchor); @@ -30,7 +27,7 @@ void main() { (game) async { await game.ready(); final position = Vector2.all(10); - final anchor = Anchor(position: position); + final anchor = JointAnchor(position: position); await game.ensureAdd(anchor); game.contains(anchor); @@ -42,7 +39,7 @@ void main() { 'is static', (game) async { await game.ready(); - final anchor = Anchor(position: Vector2.zero()); + final anchor = JointAnchor(position: Vector2.zero()); await game.ensureAdd(anchor); expect(anchor.body.bodyType, equals(BodyType.static)); @@ -54,7 +51,7 @@ void main() { flameTester.test( 'has none', (game) async { - final anchor = Anchor(position: Vector2.zero()); + final anchor = JointAnchor(position: Vector2.zero()); await game.ensureAdd(anchor); expect(anchor.body.fixtures, isEmpty); diff --git a/test/game/components/pathway_test.dart b/test/game/components/pathway_test.dart index 43af9b77..80c968d8 100644 --- a/test/game/components/pathway_test.dart +++ b/test/game/components/pathway_test.dart @@ -6,11 +6,9 @@ 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(PinballGameTest.create); + final flameTester = FlameTester(Forge2DGame.new); group('Pathway', () { const width = 50.0; diff --git a/test/game/components/plunger_test.dart b/test/game/components/plunger_test.dart index 02330b31..ecc4265e 100644 --- a/test/game/components/plunger_test.dart +++ b/test/game/components/plunger_test.dart @@ -13,7 +13,7 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final flameTester = FlameTester(PinballGameTest.create); + final flameTester = FlameTester(Forge2DGame.new); group('Plunger', () { const compressionDistance = 0.0; @@ -227,7 +227,7 @@ void main() { ); }); - final flameTester = flameBlocTester(gameBloc: gameBloc); + final flameTester = flameBlocTester(gameBloc: () => gameBloc); group('initializes with', () { flameTester.test( diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart index e60046ad..774cd675 100644 --- a/test/game/components/wall_test.dart +++ b/test/game/components/wall_test.dart @@ -10,6 +10,7 @@ import '../../helpers/helpers.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(Forge2DGame.new); group('Wall', () { group('BottomWallBallContactCallback', () { @@ -32,7 +33,6 @@ void main() { }, ); }); - final flameTester = FlameTester(PinballGameTest.create); flameTester.test( 'loads correctly', diff --git a/test/game/pinball_game_test.dart b/test/game/pinball_game_test.dart index faa55d11..f79d19d5 100644 --- a/test/game/pinball_game_test.dart +++ b/test/game/pinball_game_test.dart @@ -18,18 +18,13 @@ void main() { // [BallScorePointsCallback] once the following issue is resolved: // https://github.com/flame-engine/flame/issues/1416 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(); + final walls = game.children.where( + (component) => component is Wall && component is! BottomWall, + ); // TODO(allisonryan0002): expect 3 when launch track is added and // temporary wall is removed. expect(walls.length, 4); @@ -42,10 +37,8 @@ void main() { await game.ready(); expect( - () => game.children.singleWhere( - componentSelector(), - ), - returnsNormally, + game.children.whereType().length, + equals(1), ); }, ); @@ -54,26 +47,29 @@ void main() { 'has only one Plunger', (game) async { await game.ready(); - expect( - () => game.children.singleWhere( - (component) => component is Plunger, - ), - returnsNormally, + game.children.whereType().length, + equals(1), ); }, ); flameTester.test('has only one FlipperGroup', (game) async { await game.ready(); - expect( - () => game.children.singleWhere( - (component) => component is FlipperGroup, - ), - returnsNormally, + game.children.whereType().length, + equals(1), ); }); + + flameTester.test( + 'has two Baseboards', + (game) async { + await game.ready(); + final baseboards = game.children.whereType(); + expect(baseboards.length, 2); + }, + ); }); debugModeFlameTester.test('adds a ball on tap up', (game) async { diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 9de36cde..dcf0c001 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -94,7 +94,7 @@ void main() { whenListen( gameBloc, Stream.value(state), - initialState: state, + initialState: GameState.initial(), ); await tester.pumpApp( diff --git a/test/helpers/builders.dart b/test/helpers/builders.dart index c77e55c5..d8ffd715 100644 --- a/test/helpers/builders.dart +++ b/test/helpers/builders.dart @@ -5,14 +5,14 @@ import 'package:pinball/game/game.dart'; import 'helpers.dart'; FlameTester flameBlocTester({ - required GameBloc gameBloc, + required GameBloc Function() gameBloc, }) { return FlameTester( PinballGameTest.create, pumpWidget: (gameWidget, tester) async { await tester.pumpWidget( BlocProvider.value( - value: gameBloc, + value: gameBloc(), child: gameWidget, ), ); diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 46886752..80820c1b 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -18,6 +18,8 @@ class MockContact extends Mock implements Contact {} class MockGameBloc extends Mock implements GameBloc {} +class MockGameState extends Mock implements GameState {} + class MockThemeCubit extends Mock implements ThemeCubit {} class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { @@ -37,3 +39,5 @@ class MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { class MockTapUpInfo extends Mock implements TapUpInfo {} class MockEventPosition extends Mock implements EventPosition {} + +class MockBonusLetter extends Mock implements BonusLetter {} diff --git a/test/landing/view/landing_page_test.dart b/test/landing/view/landing_page_test.dart index ab036f9c..369f8cab 100644 --- a/test/landing/view/landing_page_test.dart +++ b/test/landing/view/landing_page_test.dart @@ -1,33 +1,71 @@ +// ignore_for_file: prefer_const_constructors + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/landing/landing.dart'; import '../../helpers/helpers.dart'; void main() { group('LandingPage', () { - testWidgets('renders TextButton', (tester) async { - await tester.pumpApp(const LandingPage()); - expect(find.byType(TextButton), findsOneWidget); + testWidgets('renders correctly', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + await tester.pumpApp(LandingPage()); + + expect(find.byType(TextButton), findsNWidgets(2)); + expect(find.text(l10n.play), findsOneWidget); + expect(find.text(l10n.howToPlay), findsOneWidget); }); - testWidgets('tapping on TextButton navigates to CharacterSelectionPage', + testWidgets('tapping on play button navigates to CharacterSelectionPage', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); final navigator = MockNavigator(); when(() => navigator.push(any())).thenAnswer((_) async {}); await tester.pumpApp( - const LandingPage(), + LandingPage(), navigator: navigator, ); - await tester.tap( - find.byType( - TextButton, - ), - ); + + await tester.tap(find.widgetWithText(TextButton, l10n.play)); verify(() => navigator.push(any())).called(1); }); + + testWidgets('tapping on how to play button displays dialog with controls', + (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + await tester.pumpApp(LandingPage()); + + await tester.tap(find.widgetWithText(TextButton, l10n.howToPlay)); + await tester.pump(); + + expect(find.byType(Dialog), findsOneWidget); + }); + }); + + group('KeyIndicator', () { + testWidgets('fromKeyName renders correctly', (tester) async { + const keyName = 'A'; + + await tester.pumpApp( + KeyIndicator.fromKeyName(keyName: keyName), + ); + + expect(find.text(keyName), findsOneWidget); + }); + + testWidgets('fromIcon renders correctly', (tester) async { + const keyIcon = Icons.keyboard_arrow_down; + + await tester.pumpApp( + KeyIndicator.fromIcon(keyIcon: keyIcon), + ); + + expect(find.byIcon(keyIcon), findsOneWidget); + }); }); }