diff --git a/.github/workflows/pinball_theme.yaml b/.github/workflows/pinball_theme.yaml new file mode 100644 index 00000000..f6fa14aa --- /dev/null +++ b/.github/workflows/pinball_theme.yaml @@ -0,0 +1,18 @@ +name: pinball_theme + +on: + push: + paths: + - "packages/pinball_theme/**" + - ".github/workflows/pinball_theme.yaml" + + pull_request: + paths: + - "packages/pinball_theme/**" + - ".github/workflows/pinball_theme.yaml" + +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 diff --git a/.gitignore b/.gitignore index bd315f72..eeb2b0f6 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,5 @@ app.*.map.json !.idea/codeStyles/ !.idea/dictionaries/ !.idea/runConfigurations/ + +.firebase diff --git a/README.md b/README.md index b51926b5..b91a1c98 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,33 @@ Update the `CFBundleLocalizations` array in the `Info.plist` at `ios/Runner/Info } ``` +### Deploy application to Firebase hosting + +Follow the following steps to deploy the application. + +## Firebase CLI + +Install and authenticate with [Firebase CLI tools](https://firebase.google.com/docs/cli) + +## Build the project using the desired environment + +```bash +# Development +$ flutter build web --release --target lib/main_development.dart + +# Staging +$ flutter build web --release --target lib/main_staging.dart + +# Production +$ flutter build web --release --target lib/main_production.dart +``` + +## Deploy + +```bash +$ firebase deploy +``` + [coverage_badge]: coverage_badge.svg [flutter_localizations_link]: https://api.flutter.dev/flutter/flutter_localizations/flutter_localizations-library.html [internationalization_link]: https://flutter.dev/docs/development/accessibility-and-localization/internationalization diff --git a/assets/images/components/ball.png b/assets/images/components/ball.png new file mode 100644 index 00000000..af80811b Binary files /dev/null and b/assets/images/components/ball.png differ diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..80e2ae69 --- /dev/null +++ b/firebase.json @@ -0,0 +1,11 @@ +{ + "hosting": { + "public": "build/web", + "site": "ashehwkdkdjruejdnensjsjdne", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ] + } +} diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart index a1d37585..31aa0498 100644 --- a/lib/game/bloc/game_bloc.dart +++ b/lib/game/bloc/game_bloc.dart @@ -11,6 +11,7 @@ class GameBloc extends Bloc { GameBloc() : super(const GameState.initial()) { on(_onBallLost); on(_onScored); + on(_onBonusLetterActivated); } void _onBallLost(BallLost event, Emitter emit) { @@ -24,4 +25,15 @@ class GameBloc extends Bloc { emit(state.copyWith(score: state.score + event.points)); } } + + void _onBonusLetterActivated(BonusLetterActivated event, Emitter emit) { + emit( + state.copyWith( + bonusLetters: [ + ...state.bonusLetters, + event.letter, + ], + ), + ); + } } diff --git a/lib/game/bloc/game_event.dart b/lib/game/bloc/game_event.dart index 060f1aac..fa57cbff 100644 --- a/lib/game/bloc/game_event.dart +++ b/lib/game/bloc/game_event.dart @@ -32,3 +32,12 @@ class Scored extends GameEvent { @override List get props => [points]; } + +class BonusLetterActivated extends GameEvent { + const BonusLetterActivated(this.letter); + + final String letter; + + @override + List get props => [letter]; +} diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart index b630884e..f8456518 100644 --- a/lib/game/bloc/game_state.dart +++ b/lib/game/bloc/game_state.dart @@ -10,12 +10,14 @@ class GameState extends Equatable { const GameState({ required this.score, required this.balls, + required this.bonusLetters, }) : assert(score >= 0, "Score can't be negative"), assert(balls >= 0, "Number of balls can't be negative"); const GameState.initial() : score = 0, - balls = 3; + balls = 3, + bonusLetters = const []; /// The current score of the game. final int score; @@ -25,12 +27,19 @@ class GameState extends Equatable { /// When the number of balls is 0, the game is over. final int balls; + /// Active bonus letters. + final List bonusLetters; + /// Determines when the game is over. bool get isGameOver => balls == 0; + /// Determines when the player has only one ball left. + bool get isLastBall => balls == 1; + GameState copyWith({ int? score, int? balls, + List? bonusLetters, }) { assert( score == null || score >= this.score, @@ -40,6 +49,7 @@ class GameState extends Equatable { return GameState( score: score ?? this.score, balls: balls ?? this.balls, + bonusLetters: bonusLetters ?? this.bonusLetters, ); } @@ -47,5 +57,6 @@ class GameState extends Equatable { List get props => [ score, balls, + bonusLetters, ]; } diff --git a/lib/game/components/anchor.dart b/lib/game/components/anchor.dart new file mode 100644 index 00000000..0e78aa1c --- /dev/null +++ b/lib/game/components/anchor.dart @@ -0,0 +1,32 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; + +/// {@template anchor} +/// Non visual [BodyComponent] used to hold a [BodyType.dynamic] in [Joint]s +/// with this [BodyType.static]. +/// +/// It is recommended to [_position] the anchor first and then use the body +/// position as the anchor point when initializing a [JointDef]. +/// +/// ```dart +/// initialize( +/// dynamicBody.body, +/// anchor.body, +/// anchor.body.position, +/// ); +/// ``` +/// {@endtemplate} +class Anchor extends BodyComponent { + /// {@macro anchor} + Anchor({ + required Vector2 position, + }) : _position = position; + + final Vector2 _position; + + @override + Body createBody() { + final bodyDef = BodyDef()..position = _position; + + return world.createBody(bodyDef); + } +} diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index b80ea029..aeb9be69 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -1,28 +1,36 @@ +import 'package:flame/components.dart'; import 'package:flame_bloc/flame_bloc.dart'; -import 'package:flame_forge2d/body_component.dart'; -import 'package:flutter/material.dart'; -import 'package:forge2d/forge2d.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/game.dart'; /// {@template ball} /// A solid, [BodyType.dynamic] sphere that rolls and bounces along the /// [PinballGame]. /// {@endtemplate} -class Ball extends BodyComponent +class Ball extends PositionBodyComponent with BlocComponent { /// {@macro ball} Ball({ required Vector2 position, - }) : _position = position { - // TODO(alestiago): Use asset instead of color when provided. - paint = Paint()..color = const Color(0xFFFFFFFF); - } + }) : _position = position, + super(size: ballSize); + + static final ballSize = Vector2.all(2); final Vector2 _position; + 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); + } + @override Body createBody() { - final shape = CircleShape()..radius = 2; + final shape = CircleShape()..radius = ballSize.x / 2; final fixtureDef = FixtureDef(shape)..density = 1; @@ -33,4 +41,15 @@ class Ball extends BodyComponent return world.createBody(bodyDef)..createFixture(fixtureDef); } + + void lost() { + shouldRemove = true; + + final bloc = gameRef.read()..add(const BallLost()); + + final shouldBallRespwan = !bloc.state.isLastBall; + if (shouldBallRespwan) { + gameRef.spawnBall(); + } + } } diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index e3d2c6ce..95134ec2 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,2 +1,5 @@ +export 'anchor.dart'; export 'ball.dart'; +export 'plunger.dart'; export 'score_points.dart'; +export 'wall.dart'; diff --git a/lib/game/components/plunger.dart b/lib/game/components/plunger.dart new file mode 100644 index 00000000..ed1ef36f --- /dev/null +++ b/lib/game/components/plunger.dart @@ -0,0 +1,72 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:pinball/game/game.dart'; + +/// {@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]. +/// {@endtemplate} +class Plunger extends BodyComponent { + /// {@macro plunger} + Plunger({required Vector2 position}) : _position = position; + + final Vector2 _position; + + @override + Body createBody() { + final shape = PolygonShape()..setAsBoxXY(2.5, 1.5); + + final fixtureDef = FixtureDef(shape); + + final bodyDef = BodyDef() + ..userData = this + ..position = _position + ..type = BodyType.dynamic + ..gravityScale = 0; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } + + /// Set a constant downward velocity on the [Plunger]. + void pull() { + body.linearVelocity = Vector2(0, -7); + } + + /// 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() { + final velocity = (_position.y - body.position.y) * 9; + body.linearVelocity = Vector2(0, velocity); + } +} + +/// {@template plunger_anchor_prismatic_joint_def} +/// [PrismaticJointDef] between a [Plunger] and an [Anchor] 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]. +/// {@endtemplate} +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', + ) { + initialize( + plunger.body, + anchor.body, + anchor.body.position, + Vector2(0, -1), + ); + enableLimit = true; + lowerTranslation = double.negativeInfinity; + collideConnected = true; + } +} diff --git a/lib/game/components/wall.dart b/lib/game/components/wall.dart new file mode 100644 index 00000000..b784b8cb --- /dev/null +++ b/lib/game/components/wall.dart @@ -0,0 +1,63 @@ +// ignore_for_file: avoid_renaming_method_parameters + +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. +/// {@endtemplate} +class Wall extends BodyComponent { + Wall({ + required this.start, + required this.end, + }); + + final Vector2 start; + final Vector2 end; + + @override + Body createBody() { + final shape = EdgeShape()..set(start, end); + + final fixtureDef = FixtureDef(shape) + ..restitution = 0.0 + ..friction = 0.3; + + final bodyDef = BodyDef() + ..userData = this + ..position = Vector2.zero() + ..type = BodyType.static; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} + +/// {@template bottom_wall} +/// [Wall] located at the bottom of the board. +/// +/// Collisions with [BottomWall] are listened by +/// [BottomWallBallContactCallback]. +/// {@endtemplate} +class BottomWall extends Wall { + BottomWall(Forge2DGame game) + : super( + start: game.screenToWorld(game.camera.viewport.effectiveSize), + end: Vector2( + 0, + game.screenToWorld(game.camera.viewport.effectiveSize).y, + ), + ); +} + +/// {@template bottom_wall_ball_contact_callback} +/// Listens when a [Ball] falls into a [BottomWall]. +/// {@endtemplate} +class BottomWallBallContactCallback extends ContactCallback { + @override + void begin(Ball ball, BottomWall wall, Contact contact) { + ball.lost(); + } + + @override + void end(_, __, ___) {} +} diff --git a/lib/game/game.dart b/lib/game/game.dart index e2e5361f..ad02533d 100644 --- a/lib/game/game.dart +++ b/lib/game/game.dart @@ -1,4 +1,5 @@ export 'bloc/game_bloc.dart'; export 'components/components.dart'; +export 'game_assets.dart'; export 'pinball_game.dart'; -export 'view/pinball_game_page.dart'; +export 'view/view.dart'; diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart new file mode 100644 index 00000000..964aeda1 --- /dev/null +++ b/lib/game/game_assets.dart @@ -0,0 +1,11 @@ +import 'package:pinball/game/game.dart'; + +/// Add methods to help loading and caching game assets. +extension PinballGameAssetsX on PinballGame { + /// Pre load the initial assets of the game. + Future preLoadAssets() async { + await Future.wait([ + images.load(Ball.spritePath), + ]); + } +} diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 1c340342..cab05c61 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -1,12 +1,38 @@ // ignore_for_file: public_member_api_docs +import 'dart:async'; + import 'package:flame_bloc/flame_bloc.dart'; -import 'package:flame_forge2d/forge2d_game.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/game/game.dart'; class PinballGame extends Forge2DGame with FlameBloc { + void spawnBall() { + add( + Ball(position: ballStartingPosition), + ); + } + + // 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); + @override Future onLoad() async { addContactCallback(BallScorePointsCallback()); + + await add(BottomWall(this)); + addContactCallback(BottomWallBallContactCallback()); + } + + @override + void onAttach() { + super.onAttach(); + spawnBall(); } } diff --git a/lib/game/view/game_hud.dart b/lib/game/view/game_hud.dart new file mode 100644 index 00000000..00eedd2b --- /dev/null +++ b/lib/game/view/game_hud.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; + +/// {@template game_hud} +/// Overlay of a [PinballGame] that displays the current [GameState.score] and +/// [GameState.balls]. +/// {@endtemplate} +class GameHud extends StatelessWidget { + /// {@macro game_hud} + const GameHud({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + + return Container( + color: Colors.redAccent, + width: 200, + height: 100, + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${state.score}', + style: Theme.of(context).textTheme.headline3, + ), + Wrap( + direction: Axis.vertical, + children: [ + for (var i = 0; i < state.balls; i++) + const Padding( + padding: EdgeInsets.only(top: 6, right: 6), + child: CircleAvatar( + radius: 8, + backgroundColor: Colors.black, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 4e36ec69..a49ff0c1 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -2,17 +2,74 @@ import 'package:flame/game.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/game/game.dart'; class PinballGamePage extends StatelessWidget { const PinballGamePage({Key? key}) : super(key: key); static Route route() { - return MaterialPageRoute(builder: (_) => const PinballGamePage()); + return MaterialPageRoute( + builder: (_) { + return BlocProvider( + create: (_) => GameBloc(), + child: const PinballGamePage(), + ); + }, + ); } @override Widget build(BuildContext context) { - return GameWidget(game: PinballGame()); + return const PinballGameView(); + } +} + +class PinballGameView extends StatefulWidget { + const PinballGameView({Key? key}) : super(key: key); + + @override + State createState() => _PinballGameViewState(); +} + +class _PinballGameViewState extends State { + late PinballGame _game; + + @override + void initState() { + super.initState(); + + // 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(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.isGameOver) { + showDialog( + context: context, + builder: (_) { + return const GameOverDialog(); + }, + ); + } + }, + child: Stack( + children: [ + Positioned.fill( + child: GameWidget(game: _game), + ), + const Positioned( + top: 8, + left: 8, + child: GameHud(), + ), + ], + ), + ); } } diff --git a/lib/game/view/view.dart b/lib/game/view/view.dart new file mode 100644 index 00000000..26b700d3 --- /dev/null +++ b/lib/game/view/view.dart @@ -0,0 +1,3 @@ +export 'game_hud.dart'; +export 'pinball_game_page.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/game/view/widgets/game_over_dialog.dart b/lib/game/view/widgets/game_over_dialog.dart new file mode 100644 index 00000000..586d6c56 --- /dev/null +++ b/lib/game/view/widgets/game_over_dialog.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class GameOverDialog extends StatelessWidget { + const GameOverDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Dialog( + child: SizedBox( + width: 200, + height: 200, + child: Center( + child: Text('Game Over'), + ), + ), + ); + } +} diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart new file mode 100644 index 00000000..9c457b1c --- /dev/null +++ b/lib/game/view/widgets/widgets.dart @@ -0,0 +1 @@ +export 'game_over_dialog.dart'; diff --git a/lib/theme/cubit/theme_cubit.dart b/lib/theme/cubit/theme_cubit.dart new file mode 100644 index 00000000..7ba79e59 --- /dev/null +++ b/lib/theme/cubit/theme_cubit.dart @@ -0,0 +1,13 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +part 'theme_state.dart'; + +class ThemeCubit extends Cubit { + ThemeCubit() : super(const ThemeState.initial()); + + void characterSelected(CharacterTheme characterTheme) { + emit(ThemeState(PinballTheme(characterTheme: characterTheme))); + } +} diff --git a/lib/theme/cubit/theme_state.dart b/lib/theme/cubit/theme_state.dart new file mode 100644 index 00000000..13b3ea5f --- /dev/null +++ b/lib/theme/cubit/theme_state.dart @@ -0,0 +1,13 @@ +part of 'theme_cubit.dart'; + +class ThemeState extends Equatable { + const ThemeState(this.theme); + + const ThemeState.initial() + : theme = const PinballTheme(characterTheme: DashTheme()); + + final PinballTheme theme; + + @override + List get props => [theme]; +} diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart new file mode 100644 index 00000000..fcf5d9ee --- /dev/null +++ b/lib/theme/theme.dart @@ -0,0 +1 @@ +export 'cubit/theme_cubit.dart'; diff --git a/packages/pinball_theme/.gitignore b/packages/pinball_theme/.gitignore new file mode 100644 index 00000000..d6130351 --- /dev/null +++ b/packages/pinball_theme/.gitignore @@ -0,0 +1,39 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/pinball_theme/README.md b/packages/pinball_theme/README.md new file mode 100644 index 00000000..e9730e1b --- /dev/null +++ b/packages/pinball_theme/README.md @@ -0,0 +1,11 @@ +# pinball_theme + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Package containing themes for pinball game. + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis \ No newline at end of file diff --git a/packages/pinball_theme/analysis_options.yaml b/packages/pinball_theme/analysis_options.yaml new file mode 100644 index 00000000..3742fc3d --- /dev/null +++ b/packages/pinball_theme/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.2.4.0.yaml \ No newline at end of file diff --git a/packages/pinball_theme/lib/pinball_theme.dart b/packages/pinball_theme/lib/pinball_theme.dart new file mode 100644 index 00000000..0206fa7b --- /dev/null +++ b/packages/pinball_theme/lib/pinball_theme.dart @@ -0,0 +1,4 @@ +library pinball_theme; + +export 'src/pinball_theme.dart'; +export 'src/themes/themes.dart'; diff --git a/packages/pinball_theme/lib/src/pinball_theme.dart b/packages/pinball_theme/lib/src/pinball_theme.dart new file mode 100644 index 00000000..a766a129 --- /dev/null +++ b/packages/pinball_theme/lib/src/pinball_theme.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// {@template pinball_theme} +/// Defines all theme assets and attributes. +/// +/// Game components should have a getter specified here to load their +/// corresponding assets for the game. +/// {@endtemplate} +class PinballTheme extends Equatable { + /// {@macro pinball_theme} + const PinballTheme({ + required CharacterTheme characterTheme, + }) : _characterTheme = characterTheme; + + final CharacterTheme _characterTheme; + + /// [CharacterTheme] for the chosen character. + CharacterTheme get characterTheme => _characterTheme; + + @override + List get props => [_characterTheme]; +} diff --git a/packages/pinball_theme/lib/src/themes/android_theme.dart b/packages/pinball_theme/lib/src/themes/android_theme.dart new file mode 100644 index 00000000..59c16bd9 --- /dev/null +++ b/packages/pinball_theme/lib/src/themes/android_theme.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// {@template android_theme} +/// Defines Android character theme assets and attributes. +/// {@endtemplate} +class AndroidTheme extends CharacterTheme { + /// {@macro android_theme} + const AndroidTheme(); + + @override + Color get ballColor => Colors.green; +} diff --git a/packages/pinball_theme/lib/src/themes/character_theme.dart b/packages/pinball_theme/lib/src/themes/character_theme.dart new file mode 100644 index 00000000..8f81486a --- /dev/null +++ b/packages/pinball_theme/lib/src/themes/character_theme.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +/// {@template character_theme} +/// Base class for creating character themes. +/// +/// Character specific game components should have a getter specified here to +/// load their corresponding assets for the game. +/// {@endtemplate} +abstract class CharacterTheme extends Equatable { + /// {@macro character_theme} + const CharacterTheme(); + + /// Ball color for this theme. + Color get ballColor; + + @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 new file mode 100644 index 00000000..e4875a11 --- /dev/null +++ b/packages/pinball_theme/lib/src/themes/dash_theme.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// {@template dash_theme} +/// Defines Dash character theme assets and attributes. +/// {@endtemplate} +class DashTheme extends CharacterTheme { + /// {@macro dash_theme} + const DashTheme(); + + @override + Color get ballColor => Colors.blue; +} diff --git a/packages/pinball_theme/lib/src/themes/dino_theme.dart b/packages/pinball_theme/lib/src/themes/dino_theme.dart new file mode 100644 index 00000000..07776771 --- /dev/null +++ b/packages/pinball_theme/lib/src/themes/dino_theme.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// {@template dino_theme} +/// Defines Dino character theme assets and attributes. +/// {@endtemplate} +class DinoTheme extends CharacterTheme { + /// {@macro dino_theme} + const DinoTheme(); + + @override + Color get ballColor => Colors.grey; +} diff --git a/packages/pinball_theme/lib/src/themes/sparky_theme.dart b/packages/pinball_theme/lib/src/themes/sparky_theme.dart new file mode 100644 index 00000000..5264bad6 --- /dev/null +++ b/packages/pinball_theme/lib/src/themes/sparky_theme.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// {@template sparky_theme} +/// Defines Sparky character theme assets and attributes. +/// {@endtemplate} +class SparkyTheme extends CharacterTheme { + /// {@macro sparky_theme} + const SparkyTheme(); + + @override + Color get ballColor => Colors.orange; +} diff --git a/packages/pinball_theme/lib/src/themes/themes.dart b/packages/pinball_theme/lib/src/themes/themes.dart new file mode 100644 index 00000000..d4062a4f --- /dev/null +++ b/packages/pinball_theme/lib/src/themes/themes.dart @@ -0,0 +1,5 @@ +export 'android_theme.dart'; +export 'character_theme.dart'; +export 'dash_theme.dart'; +export 'dino_theme.dart'; +export 'sparky_theme.dart'; diff --git a/packages/pinball_theme/pubspec.yaml b/packages/pinball_theme/pubspec.yaml new file mode 100644 index 00000000..e9b3f215 --- /dev/null +++ b/packages/pinball_theme/pubspec.yaml @@ -0,0 +1,17 @@ +name: pinball_theme +description: Package containing themes for pinball game. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.16.0 <3.0.0" + +dependencies: + equatable: ^2.0.3 + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^2.4.0 \ No newline at end of file diff --git a/packages/pinball_theme/test/src/pinball_theme_test.dart b/packages/pinball_theme/test/src/pinball_theme_test.dart new file mode 100644 index 00000000..899eec64 --- /dev/null +++ b/packages/pinball_theme/test/src/pinball_theme_test.dart @@ -0,0 +1,28 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('PinballTheme', () { + const characterTheme = SparkyTheme(); + + test('can be instantiated', () { + expect(PinballTheme(characterTheme: characterTheme), isNotNull); + }); + + test('supports value equality', () { + expect( + PinballTheme(characterTheme: characterTheme), + equals(PinballTheme(characterTheme: characterTheme)), + ); + }); + + test('characterTheme is correct', () { + expect( + PinballTheme(characterTheme: characterTheme).characterTheme, + equals(characterTheme), + ); + }); + }); +} diff --git a/packages/pinball_theme/test/src/themes/android_theme_test.dart b/packages/pinball_theme/test/src/themes/android_theme_test.dart new file mode 100644 index 00000000..a6148042 --- /dev/null +++ b/packages/pinball_theme/test/src/themes/android_theme_test.dart @@ -0,0 +1,21 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('AndroidTheme', () { + test('can be instantiated', () { + expect(AndroidTheme(), isNotNull); + }); + + test('supports value equality', () { + expect(AndroidTheme(), equals(AndroidTheme())); + }); + + test('ballColor is correct', () { + expect(AndroidTheme().ballColor, equals(Colors.green)); + }); + }); +} diff --git a/packages/pinball_theme/test/src/themes/dash_theme_test.dart b/packages/pinball_theme/test/src/themes/dash_theme_test.dart new file mode 100644 index 00000000..0d5c8293 --- /dev/null +++ b/packages/pinball_theme/test/src/themes/dash_theme_test.dart @@ -0,0 +1,21 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('DashTheme', () { + test('can be instantiated', () { + expect(DashTheme(), isNotNull); + }); + + test('supports value equality', () { + expect(DashTheme(), equals(DashTheme())); + }); + + test('ballColor is correct', () { + expect(DashTheme().ballColor, equals(Colors.blue)); + }); + }); +} diff --git a/packages/pinball_theme/test/src/themes/dino_theme_test.dart b/packages/pinball_theme/test/src/themes/dino_theme_test.dart new file mode 100644 index 00000000..6efd8cbd --- /dev/null +++ b/packages/pinball_theme/test/src/themes/dino_theme_test.dart @@ -0,0 +1,21 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('DinoTheme', () { + test('can be instantiated', () { + expect(DinoTheme(), isNotNull); + }); + + test('supports value equality', () { + expect(DinoTheme(), equals(DinoTheme())); + }); + + test('ballColor is correct', () { + expect(DinoTheme().ballColor, equals(Colors.grey)); + }); + }); +} diff --git a/packages/pinball_theme/test/src/themes/sparky_theme_test.dart b/packages/pinball_theme/test/src/themes/sparky_theme_test.dart new file mode 100644 index 00000000..513ca219 --- /dev/null +++ b/packages/pinball_theme/test/src/themes/sparky_theme_test.dart @@ -0,0 +1,21 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('SparkyTheme', () { + test('can be instantiated', () { + expect(SparkyTheme(), isNotNull); + }); + + test('supports value equality', () { + expect(SparkyTheme(), equals(SparkyTheme())); + }); + + test('ballColor is correct', () { + expect(SparkyTheme().ballColor, equals(Colors.orange)); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index e218776d..861dae5b 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.1" + version: "1.1.0-releasecandidate.2" flame_bloc: dependency: "direct main" description: name: flame_bloc url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-releasecandidate.1" + version: "1.2.0-releasecandidate.2" flame_forge2d: dependency: "direct main" description: name: flame_forge2d url: "https://pub.dartlang.org" source: hosted - version: "0.9.0-releasecandidate.1" + version: "0.9.0-releasecandidate.2" flame_test: dependency: "direct dev" description: @@ -324,6 +324,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + pinball_theme: + dependency: "direct main" + description: + path: "packages/pinball_theme" + relative: true + source: path + version: "1.0.0+1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5d708073..8738f2bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,15 +9,17 @@ environment: dependencies: bloc: ^8.0.2 equatable: ^2.0.3 - flame: ^1.1.0-releasecandidate.1 - flame_bloc: ^1.2.0-releasecandidate.1 - flame_forge2d: ^0.9.0-releasecandidate.1 + flame: ^1.1.0-releasecandidate.2 + flame_bloc: ^1.2.0-releasecandidate.2 + flame_forge2d: ^0.9.0-releasecandidate.2 flutter: sdk: flutter flutter_bloc: ^8.0.1 flutter_localizations: sdk: flutter intl: ^0.17.0 + pinball_theme: + path: packages/pinball_theme dev_dependencies: bloc_test: ^9.0.2 @@ -31,3 +33,6 @@ dev_dependencies: flutter: uses-material-design: true generate: true + + assets: + - assets/images/components/ diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart index 2676a286..bd669397 100644 --- a/test/game/bloc/game_bloc_test.dart +++ b/test/game/bloc/game_bloc_test.dart @@ -21,9 +21,9 @@ void main() { } }, expect: () => [ - const GameState(score: 0, balls: 2), - const GameState(score: 0, balls: 1), - const GameState(score: 0, balls: 0), + const GameState(score: 0, balls: 2, bonusLetters: []), + const GameState(score: 0, balls: 1, bonusLetters: []), + const GameState(score: 0, balls: 0, bonusLetters: []), ], ); }); @@ -37,8 +37,8 @@ void main() { ..add(const Scored(points: 2)) ..add(const Scored(points: 3)), expect: () => [ - const GameState(score: 2, balls: 3), - const GameState(score: 5, balls: 3), + const GameState(score: 2, balls: 3, bonusLetters: []), + const GameState(score: 5, balls: 3, bonusLetters: []), ], ); @@ -53,9 +53,55 @@ void main() { bloc.add(const Scored(points: 2)); }, expect: () => [ - const GameState(score: 0, balls: 2), - const GameState(score: 0, balls: 1), - const GameState(score: 0, balls: 0), + const GameState(score: 0, balls: 2, bonusLetters: []), + const GameState(score: 0, balls: 1, bonusLetters: []), + const GameState(score: 0, balls: 0, bonusLetters: []), + ], + ); + }); + + group('BonusLetterActivated', () { + blocTest( + 'adds the letter to the state', + build: GameBloc.new, + act: (bloc) => bloc + ..add(const BonusLetterActivated('G')) + ..add(const BonusLetterActivated('O')) + ..add(const BonusLetterActivated('O')) + ..add(const BonusLetterActivated('G')) + ..add(const BonusLetterActivated('L')) + ..add(const BonusLetterActivated('E')), + expect: () => [ + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O', 'G'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O', 'G', 'L'], + ), + const GameState( + score: 0, + balls: 3, + bonusLetters: ['G', 'O', 'O', 'G', 'L', 'E'], + ), ], ); }); diff --git a/test/game/bloc/game_event_test.dart b/test/game/bloc/game_event_test.dart index e839ab56..0e7a0f71 100644 --- a/test/game/bloc/game_event_test.dart +++ b/test/game/bloc/game_event_test.dart @@ -40,5 +40,22 @@ void main() { expect(() => Scored(points: 0), throwsAssertionError); }); }); + + group('BonusLetterActivated', () { + test('can be instantiated', () { + expect(const BonusLetterActivated('A'), isNotNull); + }); + + test('supports value equality', () { + expect( + BonusLetterActivated('A'), + equals(BonusLetterActivated('A')), + ); + expect( + BonusLetterActivated('B'), + isNot(equals(BonusLetterActivated('A'))), + ); + }); + }); }); } diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart index f62bae67..7345d3bd 100644 --- a/test/game/bloc/game_state_test.dart +++ b/test/game/bloc/game_state_test.dart @@ -7,14 +7,27 @@ void main() { group('GameState', () { test('supports value equality', () { expect( - GameState(score: 0, balls: 0), - equals(const GameState(score: 0, balls: 0)), + GameState( + score: 0, + balls: 0, + bonusLetters: const [], + ), + equals( + const GameState( + score: 0, + balls: 0, + bonusLetters: [], + ), + ), ); }); group('constructor', () { test('can be instantiated', () { - expect(const GameState(score: 0, balls: 0), isNotNull); + expect( + const GameState(score: 0, balls: 0, bonusLetters: []), + isNotNull, + ); }); }); @@ -23,7 +36,7 @@ void main() { 'when balls are negative', () { expect( - () => GameState(balls: -1, score: 0), + () => GameState(balls: -1, score: 0, bonusLetters: const []), throwsAssertionError, ); }, @@ -34,7 +47,7 @@ void main() { 'when score is negative', () { expect( - () => GameState(balls: 0, score: -1), + () => GameState(balls: 0, score: -1, bonusLetters: const []), throwsAssertionError, ); }, @@ -47,6 +60,7 @@ void main() { const gameState = GameState( balls: 0, score: 0, + bonusLetters: [], ); expect(gameState.isGameOver, isTrue); }); @@ -57,11 +71,40 @@ void main() { const gameState = GameState( balls: 1, score: 0, + bonusLetters: [], ); expect(gameState.isGameOver, isFalse); }); }); + group('isLastBall', () { + test( + 'is true ' + 'when there is only one ball left', + () { + const gameState = GameState( + balls: 1, + score: 0, + bonusLetters: [], + ); + expect(gameState.isLastBall, isTrue); + }, + ); + + test( + 'is false ' + 'when there are more balls left', + () { + const gameState = GameState( + balls: 2, + score: 0, + bonusLetters: [], + ); + expect(gameState.isLastBall, isFalse); + }, + ); + }); + group('copyWith', () { test( 'throws AssertionError ' @@ -70,6 +113,7 @@ void main() { const gameState = GameState( balls: 0, score: 2, + bonusLetters: [], ); expect( () => gameState.copyWith(score: gameState.score - 1), @@ -85,6 +129,7 @@ void main() { const gameState = GameState( balls: 0, score: 2, + bonusLetters: [], ); expect( gameState.copyWith(), @@ -100,10 +145,12 @@ void main() { const gameState = GameState( score: 2, balls: 0, + bonusLetters: [], ); final otherGameState = GameState( score: gameState.score + 1, balls: gameState.balls + 1, + bonusLetters: const ['A'], ); expect(gameState, isNot(equals(otherGameState))); @@ -111,6 +158,7 @@ void main() { gameState.copyWith( score: otherGameState.score, balls: otherGameState.balls, + bonusLetters: otherGameState.bonusLetters, ), equals(otherGameState), ); diff --git a/test/game/components/anchor_test.dart b/test/game/components/anchor_test.dart new file mode 100644 index 00000000..5cc37eca --- /dev/null +++ b/test/game/components/anchor_test.dart @@ -0,0 +1,60 @@ +// 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() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Anchor', () { + final flameTester = FlameTester(PinballGame.new); + + flameTester.test( + 'loads correctly', + (game) async { + final anchor = Anchor(position: Vector2.zero()); + await game.ensureAdd(anchor); + + expect(game.contains(anchor), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final anchor = Anchor(position: position); + await game.ensureAdd(anchor); + game.contains(anchor); + + expect(anchor.body.position, position); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final anchor = Anchor(position: Vector2.zero()); + await game.ensureAdd(anchor); + + expect(anchor.body.bodyType, equals(BodyType.static)); + }, + ); + }); + + group('fixtures', () { + flameTester.test( + 'has none', + (game) async { + final anchor = Anchor(position: Vector2.zero()); + await game.ensureAdd(anchor); + + expect(anchor.body.fixtures, isEmpty); + }, + ); + }); + }); +} diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index c4576c68..bd2cbcfc 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -1,10 +1,14 @@ // ignore_for_file: cascade_invocations +import 'package:bloc_test/bloc_test.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(); @@ -75,7 +79,77 @@ void main() { final fixture = ball.body.fixtures[0]; expect(fixture.shape.shapeType, equals(ShapeType.circle)); - expect(fixture.shape.radius, equals(2)); + expect(fixture.shape.radius, equals(1)); + }, + ); + }); + + group('resetting a ball', () { + late GameBloc gameBloc; + + setUp(() { + gameBloc = MockGameBloc(); + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + final tester = flameBlocTester( + gameBlocBuilder: () { + return gameBloc; + }, + ); + + tester.widgetTest( + 'adds BallLost to GameBloc', + (game, tester) async { + await game.ready(); + + game.children.whereType().first.lost(); + await tester.pump(); + + verify(() => gameBloc.add(const BallLost())).called(1); + }, + ); + + tester.widgetTest( + 'resets the ball if the game is not over', + (game, tester) async { + await game.ready(); + + game.children.whereType().first.removeFromParent(); + await game.ready(); // Making sure that all additions are done + + expect( + game.children.whereType().length, + equals(1), + ); + }, + ); + + tester.widgetTest( + 'no ball is added on game over', + (game, tester) async { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState( + score: 10, + balls: 1, + bonusLetters: [], + ), + ); + await game.ready(); + + game.children.whereType().first.removeFromParent(); + await tester.pump(); + + expect( + game.children.whereType().length, + equals(0), + ); }, ); }); diff --git a/test/game/components/plunger_test.dart b/test/game/components/plunger_test.dart new file mode 100644 index 00000000..67e215fd --- /dev/null +++ b/test/game/components/plunger_test.dart @@ -0,0 +1,302 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +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() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGame.new); + + group('Plunger', () { + flameTester.test( + 'loads correctly', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(game.contains(plunger), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final position = Vector2.all(10); + final plunger = Plunger(position: position); + await game.ensureAdd(plunger); + game.contains(plunger); + + expect(plunger.body.position, position); + }, + ); + + flameTester.test( + 'is dynamic', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(plunger.body.bodyType, equals(BodyType.dynamic)); + }, + ); + + flameTester.test( + 'ignores gravity', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(plunger.body.gravityScale, isZero); + }, + ); + }); + + group('first fixture', () { + flameTester.test( + 'exists', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + expect(plunger.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'shape is a polygon', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + 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', + (game) async { + final plunger = Plunger(position: Vector2.zero()); + await game.ensureAdd(plunger); + + plunger.release(); + + expect(plunger.body.linearVelocity.y, isZero); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + + 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); + + plunger.body.setTransform(Vector2(0, -1), 0); + plunger.release(); + + expect(plunger.body.linearVelocity.y, isPositive); + expect(plunger.body.linearVelocity.x, isZero); + }, + ); + }); + }); + + group('PlungerAnchorPrismaticJointDef', () { + late GameBloc gameBloc; + 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)); + }); + + 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, + ); + }, + ); + + group('initializes with', () { + flameTester.test( + 'plunger body as bodyA', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + + expect(jointDef.bodyA, equals(plunger.body)); + }, + ); + + flameTester.test( + 'anchor body as bodyB', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.bodyB, equals(anchor.body)); + }, + ); + + flameTester.test( + 'limits enabled', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.enableLimit, isTrue); + }, + ); + + flameTester.test( + 'lower translation limit as negative infinity', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.lowerTranslation, equals(double.negativeInfinity)); + }, + ); + + flameTester.test( + 'connected body collison enabled', + (game) async { + await game.ensureAddAll([plunger, anchor]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + expect(jointDef.collideConnected, isTrue); + }, + ); + }); + + flameTester.widgetTest( + 'plunger cannot go below anchor', + (game, tester) async { + await game.ensureAddAll([plunger, anchor]); + + // 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); + + plunger.pull(); + await tester.pump(const Duration(seconds: 1)); + + 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]); + + final jointDef = PlungerAnchorPrismaticJointDef( + plunger: plunger, + anchor: anchor, + ); + game.world.createJoint(jointDef); + + plunger.pull(); + await tester.pump(const Duration(seconds: 1)); + + plunger.release(); + await tester.pump(const Duration(seconds: 1)); + + expect(plunger.body.position.y < 1, isTrue); + }, + ); + }); +} diff --git a/test/game/components/wall_test.dart b/test/game/components/wall_test.dart new file mode 100644 index 00000000..8151055e --- /dev/null +++ b/test/game/components/wall_test.dart @@ -0,0 +1,122 @@ +// 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:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Wall', () { + group('BottomWallBallContactCallback', () { + test( + 'removes the ball on begin contact when the wall is a bottom one', + () { + final game = MockPinballGame(); + final wall = MockBottomWall(); + final ball = MockBall(); + + when(() => ball.gameRef).thenReturn(game); + + BottomWallBallContactCallback() + // Remove once https://github.com/flame-engine/flame/pull/1415 + // is merged + ..end(MockBall(), MockBottomWall(), MockContact()) + ..begin(ball, wall, MockContact()); + + verify(ball.lost).called(1); + }, + ); + }); + final flameTester = FlameTester(PinballGame.new); + + flameTester.test( + 'loads correctly', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + expect(game.contains(wall), isTrue); + }, + ); + + group('body', () { + flameTester.test( + 'positions correctly', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + game.contains(wall); + + expect(wall.body.position, Vector2.zero()); + }, + ); + + flameTester.test( + 'is static', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + expect(wall.body.bodyType, equals(BodyType.static)); + }, + ); + }); + + group('first fixture', () { + flameTester.test( + 'exists', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + expect(wall.body.fixtures[0], isA()); + }, + ); + + flameTester.test( + 'has restitution equals 0', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + final fixture = wall.body.fixtures[0]; + expect(fixture.restitution, equals(0)); + }, + ); + + flameTester.test( + 'has friction', + (game) async { + final wall = Wall( + start: Vector2.zero(), + end: Vector2(100, 0), + ); + await game.ensureAdd(wall); + + final fixture = wall.body.fixtures[0]; + expect(fixture.friction, greaterThan(0)); + }, + ); + }); + }); +} diff --git a/test/game/view/game_hud_test.dart b/test/game/view/game_hud_test.dart new file mode 100644 index 00000000..e7334e41 --- /dev/null +++ b/test/game/view/game_hud_test.dart @@ -0,0 +1,79 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; +import '../../helpers/helpers.dart'; + +void main() { + group('GameHud', () { + late GameBloc gameBloc; + const initialState = GameState(score: 10, balls: 2, bonusLetters: []); + + void _mockState(GameState state) { + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + } + + Future _pumpHud(WidgetTester tester) async { + await tester.pumpApp( + GameHud(), + gameBloc: gameBloc, + ); + } + + setUp(() { + gameBloc = MockGameBloc(); + _mockState(initialState); + }); + + testWidgets( + 'renders the current score', + (tester) async { + await _pumpHud(tester); + expect(find.text(initialState.score.toString()), findsOneWidget); + }, + ); + + testWidgets( + 'renders the current ball number', + (tester) async { + await _pumpHud(tester); + expect( + find.byType(CircleAvatar), + findsNWidgets(initialState.balls), + ); + }, + ); + + testWidgets('updates the score', (tester) async { + await _pumpHud(tester); + expect(find.text(initialState.score.toString()), findsOneWidget); + + _mockState(initialState.copyWith(score: 20)); + + await tester.pump(); + expect(find.text('20'), findsOneWidget); + }); + + testWidgets('updates the ball number', (tester) async { + await _pumpHud(tester); + expect( + find.byType(CircleAvatar), + findsNWidgets(initialState.balls), + ); + + _mockState(initialState.copyWith(balls: 1)); + + await tester.pump(); + expect( + find.byType(CircleAvatar), + findsNWidgets(1), + ); + }); + }); +} diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 955ce763..746dc2c7 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -1,4 +1,6 @@ +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'; @@ -6,9 +8,84 @@ import '../../helpers/helpers.dart'; void main() { group('PinballGamePage', () { - testWidgets('renders single GameWidget with PinballGame', (tester) async { - await tester.pumpApp(const PinballGamePage()); - expect(find.byType(GameWidget), findsOneWidget); + testWidgets('renders PinballGameView', (tester) async { + final gameBloc = MockGameBloc(); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); + + await tester.pumpApp(const PinballGamePage(), gameBloc: gameBloc); + expect(find.byType(PinballGameView), 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(PinballGamePage.route()); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Tap me')); + + // We can't use pumpAndSettle here because the page renders a Flame game + // which is an infinity animation, so it will timeout + await tester.pump(); // Runs the button action + await tester.pump(); // Runs the navigation + + expect(find.byType(PinballGamePage), findsOneWidget); + }); + }); + + group('PinballGameView', () { + testWidgets('renders game and a hud', (tester) async { + final gameBloc = MockGameBloc(); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); + + await tester.pumpApp(const PinballGameView(), gameBloc: gameBloc); + expect( + find.byWidgetPredicate((w) => w is GameWidget), + findsOneWidget, + ); + expect( + find.byType(GameHud), + findsOneWidget, + ); + }); + + testWidgets( + 'renders a game over dialog when the user has lost', + (tester) async { + final gameBloc = MockGameBloc(); + const state = GameState(score: 0, balls: 0, bonusLetters: []); + whenListen( + gameBloc, + Stream.value(state), + initialState: state, + ); + + await tester.pumpApp(const PinballGameView(), gameBloc: gameBloc); + await tester.pump(); + + expect( + find.text('Game Over'), + findsOneWidget, + ); + }, + ); }); } diff --git a/test/helpers/builders.dart b/test/helpers/builders.dart new file mode 100644 index 00000000..e124052e --- /dev/null +++ b/test/helpers/builders.dart @@ -0,0 +1,19 @@ +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; + +FlameTester flameBlocTester({ + required GameBloc Function() gameBlocBuilder, +}) { + return FlameTester( + PinballGame.new, + pumpWidget: (gameWidget, tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: gameBlocBuilder(), + child: gameWidget, + ), + ); + }, + ); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 695f8309..97bc22be 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -5,4 +5,6 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +export 'builders.dart'; +export 'mocks.dart'; export 'pump_app.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart new file mode 100644 index 00000000..b46e2c5c --- /dev/null +++ b/test/helpers/mocks.dart @@ -0,0 +1,15 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; + +class MockPinballGame extends Mock implements PinballGame {} + +class MockWall extends Mock implements Wall {} + +class MockBottomWall extends Mock implements BottomWall {} + +class MockBall extends Mock implements Ball {} + +class MockContact extends Mock implements Contact {} + +class MockGameBloc extends Mock implements GameBloc {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index 97ca4590..2c1efd9f 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -6,15 +6,20 @@ // https://opensource.org/licenses/MIT. import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; 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 'helpers.dart'; + extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { MockNavigator? navigator, + GameBloc? gameBloc, }) { return pumpWidget( MaterialApp( @@ -23,9 +28,12 @@ extension PumpApp on WidgetTester { GlobalMaterialLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: navigator != null - ? MockNavigatorProvider(navigator: navigator, child: widget) - : widget, + home: BlocProvider.value( + value: gameBloc ?? MockGameBloc(), + child: navigator != null + ? MockNavigatorProvider(navigator: navigator, child: widget) + : widget, + ), ), ); } diff --git a/test/theme/cubit/theme_cubit_test.dart b/test/theme/cubit/theme_cubit_test.dart new file mode 100644 index 00000000..1f2d24e0 --- /dev/null +++ b/test/theme/cubit/theme_cubit_test.dart @@ -0,0 +1,22 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/theme/theme.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('ThemeCubit', () { + test('initial state has Dash character theme', () { + final themeCubit = ThemeCubit(); + expect(themeCubit.state.theme.characterTheme, equals(const DashTheme())); + }); + + blocTest( + 'charcterSelected emits selected character theme', + build: ThemeCubit.new, + act: (bloc) => bloc.characterSelected(const SparkyTheme()), + expect: () => [ + const ThemeState(PinballTheme(characterTheme: SparkyTheme())), + ], + ); + }); +} diff --git a/test/theme/cubit/theme_state_test.dart b/test/theme/cubit/theme_state_test.dart new file mode 100644 index 00000000..49a2a387 --- /dev/null +++ b/test/theme/cubit/theme_state_test.dart @@ -0,0 +1,19 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/theme/theme.dart'; + +void main() { + group('ThemeState', () { + test('can be instantiated', () { + expect(const ThemeState.initial(), isNotNull); + }); + + test('supports value equality', () { + expect( + ThemeState.initial(), + equals(const ThemeState.initial()), + ); + }); + }); +}