diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index adfbef49..c771b9d8 100644 --- a/lib/game/components/board.dart +++ b/lib/game/components/board.dart @@ -1,11 +1,9 @@ import 'package:flame/components.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; /// {@template board} -/// The main flat surface of the [PinballGame], where the [Flipper]s, -/// [RoundBumper]s, [Kicker]s are arranged. -/// {entemplate} +/// The main flat surface of the [PinballGame]. +/// {endtemplate} class Board extends Component { /// {@macro board} Board(); @@ -21,7 +19,7 @@ class Board extends Component { spacing: 2, ); - final dashForest = _FlutterForest( + final flutterForest = FlutterForest( position: Vector2( PinballGame.boardBounds.center.dx + 20, PinballGame.boardBounds.center.dy + 48, @@ -30,44 +28,7 @@ class Board extends Component { await addAll([ bottomGroup, - dashForest, - ]); - } -} - -/// {@template flutter_forest} -/// Area positioned at the top right of the [Board] where the [Ball] -/// can bounce off [RoundBumper]s. -/// {@endtemplate} -class _FlutterForest extends Component { - /// {@macro flutter_forest} - _FlutterForest({ - required this.position, - }); - - final Vector2 position; - - @override - Future onLoad() async { - // TODO(alestiago): adjust positioning once sprites are added. - // TODO(alestiago): Use [NestBumper] instead of [RoundBumper] once provided. - final smallLeftNest = RoundBumper( - radius: 1, - points: 10, - )..initialPosition = position + Vector2(-4.8, 2.8); - final smallRightNest = RoundBumper( - radius: 1, - points: 10, - )..initialPosition = position + Vector2(0.5, -5.5); - final bigNest = RoundBumper( - radius: 2, - points: 20, - )..initialPosition = position; - - await addAll([ - smallLeftNest, - smallRightNest, - bigNest, + flutterForest, ]); } } diff --git a/lib/game/components/bonus_word.dart b/lib/game/components/bonus_word.dart index d97a9bd1..9dc9b0b0 100644 --- a/lib/game/components/bonus_word.dart +++ b/lib/game/components/bonus_word.dart @@ -21,13 +21,9 @@ class BonusWord extends Component with BlocComponent { @override bool listenWhen(GameState? previousState, GameState newState) { - if ((previousState?.bonusHistory.length ?? 0) < + return (previousState?.bonusHistory.length ?? 0) < newState.bonusHistory.length && - newState.bonusHistory.last == GameBonus.word) { - return true; - } - - return false; + newState.bonusHistory.last == GameBonus.word; } @override diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 1ed293da..07b036f6 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -4,6 +4,7 @@ export 'board.dart'; export 'board_side.dart'; export 'bonus_word.dart'; export 'flipper.dart'; +export 'flutter_forest.dart'; export 'jetpack_ramp.dart'; export 'joint_anchor.dart'; export 'kicker.dart'; @@ -11,7 +12,6 @@ export 'launcher_ramp.dart'; export 'pathway.dart'; export 'plunger.dart'; export 'ramp_opening.dart'; -export 'round_bumper.dart'; export 'score_points.dart'; export 'spaceship.dart'; export 'wall.dart'; diff --git a/lib/game/components/flutter_forest.dart b/lib/game/components/flutter_forest.dart new file mode 100644 index 00000000..51dcd90a --- /dev/null +++ b/lib/game/components/flutter_forest.dart @@ -0,0 +1,131 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/flame/blueprint.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template flutter_forest} +/// Area positioned at the top right of the [Board] where the [Ball] +/// can bounce off [DashNestBumper]s. +/// +/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest] +/// is awarded, and the [BigDashNestBumper] releases a new [Ball]. +/// {@endtemplate} +// TODO(alestiago): Make a [Blueprint] once nesting [Blueprint] is implemented. +class FlutterForest extends Component + with HasGameRef, BlocComponent { + /// {@macro flutter_forest} + FlutterForest({ + required this.position, + }); + + /// The position of the [FlutterForest] on the [Board]. + final Vector2 position; + + @override + bool listenWhen(GameState? previousState, GameState newState) { + return (previousState?.bonusHistory.length ?? 0) < + newState.bonusHistory.length && + newState.bonusHistory.last == GameBonus.dashNest; + } + + @override + void onNewState(GameState state) { + super.onNewState(state); + gameRef.addFromBlueprint(BallBlueprint(position: position)); + } + + @override + Future onLoad() async { + gameRef.addContactCallback(DashNestBumperBallContactCallback()); + + // TODO(alestiago): adjust positioning once sprites are added. + final smallLeftNest = SmallDashNestBumper(id: 'small_left_nest') + ..initialPosition = position + Vector2(-4.8, 2.8); + final smallRightNest = SmallDashNestBumper(id: 'small_right_nest') + ..initialPosition = position + Vector2(0.5, -5.5); + final bigNest = BigDashNestBumper(id: 'big_nest') + ..initialPosition = position; + + await addAll([ + smallLeftNest, + smallRightNest, + bigNest, + ]); + } +} + +/// {@template dash_nest_bumper} +/// Bumper located in the [FlutterForest]. +/// {@endtemplate} +@visibleForTesting +abstract class DashNestBumper extends BodyComponent + with ScorePoints, InitialPosition { + /// {@macro dash_nest_bumper} + DashNestBumper({required this.id}); + + /// Unique identifier for this [DashNestBumper]. + /// + /// Used to identify [DashNestBumper]s in [GameState.activatedDashNests]. + final String id; +} + +/// Listens when a [Ball] bounces bounces against a [DashNestBumper]. +@visibleForTesting +class DashNestBumperBallContactCallback + extends ContactCallback { + @override + void begin(DashNestBumper dashNestBumper, Ball ball, Contact _) { + dashNestBumper.gameRef.read().add( + DashNestActivated(dashNestBumper.id), + ); + } +} + +/// {@macro dash_nest_bumper} +@visibleForTesting +class BigDashNestBumper extends DashNestBumper { + /// {@macro dash_nest_bumper} + BigDashNestBumper({required String id}) : super(id: id); + + @override + int get points => 20; + + @override + Body createBody() { + final shape = CircleShape()..radius = 2.5; + final fixtureDef = FixtureDef(shape); + + final bodyDef = BodyDef() + ..position = initialPosition + ..userData = this; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} + +/// {@macro dash_nest_bumper} +@visibleForTesting +class SmallDashNestBumper extends DashNestBumper { + /// {@macro dash_nest_bumper} + SmallDashNestBumper({required String id}) : super(id: id); + + @override + int get points => 10; + + @override + Body createBody() { + final shape = CircleShape()..radius = 1; + final fixtureDef = FixtureDef(shape); + + final bodyDef = BodyDef() + ..position = initialPosition + ..userData = this; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} diff --git a/lib/game/components/round_bumper.dart b/lib/game/components/round_bumper.dart deleted file mode 100644 index 969bddbe..00000000 --- a/lib/game/components/round_bumper.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; - -/// {@template round_bumper} -/// Circular body that repels a [Ball] on contact, increasing the score. -/// {@endtemplate} -class RoundBumper extends BodyComponent with ScorePoints, InitialPosition { - /// {@macro round_bumper} - RoundBumper({ - required double radius, - required int points, - }) : _radius = radius, - _points = points; - - /// The radius of the [RoundBumper]. - final double _radius; - - /// Points awarded from hitting this [RoundBumper]. - final int _points; - - @override - int get points => _points; - - @override - Body createBody() { - final shape = CircleShape()..radius = _radius; - - final fixtureDef = FixtureDef(shape)..restitution = 1; - - final bodyDef = BodyDef()..position = initialPosition; - - return world.createBody(bodyDef)..createFixture(fixtureDef); - } -} diff --git a/test/game/components/board_test.dart b/test/game/components/board_test.dart index f0cd0e16..5a4b95dc 100644 --- a/test/game/components/board_test.dart +++ b/test/game/components/board_test.dart @@ -75,15 +75,15 @@ void main() { ); flameTester.test( - 'has three RoundBumpers', + 'has one FlutterForest', (game) async { // TODO(alestiago): change to [NestBumpers] once provided. final board = Board(); await game.ready(); await game.ensureAdd(board); - final roundBumpers = board.descendants().whereType(); - expect(roundBumpers.length, equals(3)); + final flutterForest = board.descendants().whereType(); + expect(flutterForest.length, equals(1)); }, ); }); diff --git a/test/game/components/bonus_word_test.dart b/test/game/components/bonus_word_test.dart index a12a5a74..afd69935 100644 --- a/test/game/components/bonus_word_test.dart +++ b/test/game/components/bonus_word_test.dart @@ -194,6 +194,7 @@ void main() { group('bonus letter activation', () { final gameBloc = MockGameBloc(); + final tester = flameBlocTester(gameBloc: () => gameBloc); BonusLetter _getBonusLetter(PinballGame game) { return game.children @@ -212,8 +213,6 @@ void main() { ); }); - final tester = flameBlocTester(gameBloc: () => gameBloc); - tester.widgetTest( 'adds BonusLetterActivated to GameBloc when not activated', (game, tester) async { diff --git a/test/game/components/flutter_forest_test.dart b/test/game/components/flutter_forest_test.dart new file mode 100644 index 00000000..f960796c --- /dev/null +++ b/test/game/components/flutter_forest_test.dart @@ -0,0 +1,125 @@ +// 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 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + group('FlutterForest', () { + flameTester.test( + 'loads correctly', + (game) async { + await game.ready(); + final flutterForest = FlutterForest(position: Vector2(0, 0)); + await game.ensureAdd(flutterForest); + + expect(game.contains(flutterForest), isTrue); + }, + ); + + flameTester.test( + 'onNewState adds a new ball', + (game) async { + final flutterForest = FlutterForest(position: Vector2(0, 0)); + await game.ready(); + await game.ensureAdd(flutterForest); + + final previousBalls = game.descendants().whereType().length; + flutterForest.onNewState(MockGameState()); + await game.ready(); + + expect( + game.descendants().whereType().length, + greaterThan(previousBalls), + ); + }, + ); + + group('listenWhen', () { + final gameBloc = MockGameBloc(); + final tester = flameBlocTester(gameBloc: () => gameBloc); + + setUp(() { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + tester.widgetTest( + 'listens when a Bonus.dashNest is added', + (game, tester) async { + await game.ready(); + final flutterForest = + game.descendants().whereType().first; + + const state = GameState( + score: 0, + balls: 3, + activatedBonusLetters: [], + activatedDashNests: {}, + bonusHistory: [GameBonus.dashNest], + ); + + expect( + flutterForest.listenWhen(const GameState.initial(), state), + isTrue, + ); + }, + ); + }); + }); + + group('DashNestBumperBallContactCallback', () { + final gameBloc = MockGameBloc(); + final tester = flameBlocTester(gameBloc: () => gameBloc); + + setUp(() { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + tester.widgetTest( + 'adds a DashNestActivated event with DashNestBumper.id', + (game, tester) async { + final contactCallback = DashNestBumperBallContactCallback(); + const id = '0'; + final dashNestBumper = MockDashNestBumper(); + when(() => dashNestBumper.id).thenReturn(id); + when(() => dashNestBumper.gameRef).thenReturn(game); + + contactCallback.begin(dashNestBumper, MockBall(), MockContact()); + + verify(() => gameBloc.add(DashNestActivated(dashNestBumper.id))) + .called(1); + }, + ); + }); + + group('BigDashNestBumper', () { + test('has points', () { + final dashNestBumper = BigDashNestBumper(id: ''); + expect(dashNestBumper.points, greaterThan(0)); + }); + }); + + group('SmallDashNestBumper', () { + test('has points', () { + final dashNestBumper = SmallDashNestBumper(id: ''); + expect(dashNestBumper.points, greaterThan(0)); + }); + }); +} diff --git a/test/game/components/round_bumper_test.dart b/test/game/components/round_bumper_test.dart deleted file mode 100644 index 437167ad..00000000 --- a/test/game/components/round_bumper_test.dart +++ /dev/null @@ -1,102 +0,0 @@ -// 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('RoundBumper', () { - final flameTester = FlameTester(Forge2DGame.new); - const radius = 1.0; - const points = 1; - - flameTester.test( - 'loads correctly', - (game) async { - await game.ready(); - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(game.contains(roundBumper), isTrue); - }, - ); - - flameTester.test( - 'has points', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(roundBumper.points, equals(points)); - }, - ); - - group('body', () { - flameTester.test( - 'is static', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(roundBumper.body.bodyType, equals(BodyType.static)); - }, - ); - }); - - group('fixture', () { - flameTester.test( - 'exists', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(roundBumper.body.fixtures[0], isA()); - }, - ); - - flameTester.test( - 'has restitution', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - final fixture = roundBumper.body.fixtures[0]; - expect(fixture.restitution, greaterThan(0)); - }, - ); - - flameTester.test( - 'shape is circular', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - final fixture = roundBumper.body.fixtures[0]; - expect(fixture.shape.shapeType, equals(ShapeType.circle)); - expect(fixture.shape.radius, equals(1)); - }, - ); - }); - }); -} diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index bd9f82cf..8ddab690 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -68,3 +68,5 @@ class MockSpaceshipEntrance extends Mock implements SpaceshipEntrance {} class MockSpaceshipHole extends Mock implements SpaceshipHole {} class MockComponentSet extends Mock implements ComponentSet {} + +class MockDashNestBumper extends Mock implements DashNestBumper {}