diff --git a/lib/game/bloc/game_bloc.dart b/lib/game/bloc/game_bloc.dart new file mode 100644 index 00000000..71c527a8 --- /dev/null +++ b/lib/game/bloc/game_bloc.dart @@ -0,0 +1,25 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +part 'game_event.dart'; +part 'game_state.dart'; + +class GameBloc extends Bloc { + GameBloc() : super(const GameState.initial()) { + on(_onBallLost); + on(_onScored); + } + + void _onBallLost(BallLost event, Emitter emit) { + if (state.balls > 0) { + emit(state.copyWith(balls: state.balls - 1)); + } + } + + void _onScored(Scored event, Emitter emit) { + if (!state.isGameOver) { + emit(state.copyWith(score: state.score + event.points)); + } + } +} diff --git a/lib/game/bloc/game_event.dart b/lib/game/bloc/game_event.dart new file mode 100644 index 00000000..88ef265b --- /dev/null +++ b/lib/game/bloc/game_event.dart @@ -0,0 +1,26 @@ +part of 'game_bloc.dart'; + +@immutable +abstract class GameEvent extends Equatable { + const GameEvent(); +} + +/// Event added when a user drops a ball off the screen. +class BallLost extends GameEvent { + const BallLost(); + + @override + List get props => []; +} + +/// Event added when a user increases their score. +class Scored extends GameEvent { + const Scored({ + required this.points, + }) : assert(points > 0, 'Points must be greater than 0'); + + final int points; + + @override + List get props => [points]; +} diff --git a/lib/game/bloc/game_state.dart b/lib/game/bloc/game_state.dart new file mode 100644 index 00000000..235e264d --- /dev/null +++ b/lib/game/bloc/game_state.dart @@ -0,0 +1,49 @@ +part of 'game_bloc.dart'; + +/// {@template game_state} +/// Represents the state of the pinball game. +/// {@endtemplate} +class GameState extends Equatable { + /// {@macro game_state} + const GameState({ + required this.score, + required this.balls, + }) : 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; + + /// The current score of the game. + final int score; + + /// The number of balls left in the game. + /// + /// When the number of balls is 0, the game is over. + final int balls; + + /// Determines when the game is over. + bool get isGameOver => balls == 0; + + GameState copyWith({ + int? score, + int? balls, + }) { + assert( + score == null || score >= this.score, + "Score can't be decreased", + ); + + return GameState( + score: score ?? this.score, + balls: balls ?? this.balls, + ); + } + + @override + List get props => [ + score, + balls, + ]; +} diff --git a/lib/game/game.dart b/lib/game/game.dart index 0a8dac1b..e2e5361f 100644 --- a/lib/game/game.dart +++ b/lib/game/game.dart @@ -1,3 +1,4 @@ +export 'bloc/game_bloc.dart'; export 'components/components.dart'; export 'pinball_game.dart'; export 'view/pinball_game_page.dart'; diff --git a/pubspec.lock b/pubspec.lock index cff109ca..e218776d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.1" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3730daa3..5d708073 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ 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 diff --git a/test/game/bloc/game_bloc_test.dart b/test/game/bloc/game_bloc_test.dart new file mode 100644 index 00000000..2676a286 --- /dev/null +++ b/test/game/bloc/game_bloc_test.dart @@ -0,0 +1,63 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + group('GameBloc', () { + test('initial state has 3 balls and empty score', () { + final gameBloc = GameBloc(); + expect(gameBloc.state.score, equals(0)); + expect(gameBloc.state.balls, equals(3)); + }); + + group('LostBall', () { + blocTest( + "doesn't decrease ball " + 'when no balls left', + build: GameBloc.new, + act: (bloc) { + for (var i = 0; i <= bloc.state.balls; i++) { + bloc.add(const BallLost()); + } + }, + expect: () => [ + const GameState(score: 0, balls: 2), + const GameState(score: 0, balls: 1), + const GameState(score: 0, balls: 0), + ], + ); + }); + + group('Scored', () { + blocTest( + 'increases score ' + 'when game is not over', + build: GameBloc.new, + act: (bloc) => bloc + ..add(const Scored(points: 2)) + ..add(const Scored(points: 3)), + expect: () => [ + const GameState(score: 2, balls: 3), + const GameState(score: 5, balls: 3), + ], + ); + + blocTest( + "doesn't increase score " + 'when game is over', + build: GameBloc.new, + act: (bloc) { + for (var i = 0; i < bloc.state.balls; i++) { + bloc.add(const BallLost()); + } + 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), + ], + ); + }); + }); +} diff --git a/test/game/bloc/game_event_test.dart b/test/game/bloc/game_event_test.dart new file mode 100644 index 00000000..e839ab56 --- /dev/null +++ b/test/game/bloc/game_event_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + group('GameEvent', () { + group('BallLost', () { + test('can be instantiated', () { + expect(const BallLost(), isNotNull); + }); + + test('supports value equality', () { + expect( + BallLost(), + equals(const BallLost()), + ); + }); + }); + + group('Scored', () { + test('can be instantiated', () { + expect(const Scored(points: 1), isNotNull); + }); + + test('supports value equality', () { + expect( + Scored(points: 1), + equals(const Scored(points: 1)), + ); + expect( + const Scored(points: 1), + isNot(equals(const Scored(points: 2))), + ); + }); + + test( + 'throws AssertionError ' + 'when score is smaller than 1', () { + expect(() => Scored(points: 0), throwsAssertionError); + }); + }); + }); +} diff --git a/test/game/bloc/game_state_test.dart b/test/game/bloc/game_state_test.dart new file mode 100644 index 00000000..f62bae67 --- /dev/null +++ b/test/game/bloc/game_state_test.dart @@ -0,0 +1,121 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + group('GameState', () { + test('supports value equality', () { + expect( + GameState(score: 0, balls: 0), + equals(const GameState(score: 0, balls: 0)), + ); + }); + + group('constructor', () { + test('can be instantiated', () { + expect(const GameState(score: 0, balls: 0), isNotNull); + }); + }); + + test( + 'throws AssertionError ' + 'when balls are negative', + () { + expect( + () => GameState(balls: -1, score: 0), + throwsAssertionError, + ); + }, + ); + + test( + 'throws AssertionError ' + 'when score is negative', + () { + expect( + () => GameState(balls: 0, score: -1), + throwsAssertionError, + ); + }, + ); + + group('isGameOver', () { + test( + 'is true ' + 'when no balls are left', () { + const gameState = GameState( + balls: 0, + score: 0, + ); + expect(gameState.isGameOver, isTrue); + }); + + test( + 'is false ' + 'when one 1 ball left', () { + const gameState = GameState( + balls: 1, + score: 0, + ); + expect(gameState.isGameOver, isFalse); + }); + }); + + group('copyWith', () { + test( + 'throws AssertionError ' + 'when scored is decreased', + () { + const gameState = GameState( + balls: 0, + score: 2, + ); + expect( + () => gameState.copyWith(score: gameState.score - 1), + throwsAssertionError, + ); + }, + ); + + test( + 'copies correctly ' + 'when no arguement specified', + () { + const gameState = GameState( + balls: 0, + score: 2, + ); + expect( + gameState.copyWith(), + equals(gameState), + ); + }, + ); + + test( + 'copies correctly ' + 'when all arguements specified', + () { + const gameState = GameState( + score: 2, + balls: 0, + ); + final otherGameState = GameState( + score: gameState.score + 1, + balls: gameState.balls + 1, + ); + expect(gameState, isNot(equals(otherGameState))); + + expect( + gameState.copyWith( + score: otherGameState.score, + balls: otherGameState.balls, + ), + equals(otherGameState), + ); + }, + ); + }); + }); +}