feat: adding bonus letters to board (#35)

* feat: adding ball spawming upon click on debug mode

* PR suggestions

* fix: rebase

* feat: adding bonus letter component

* feat: bonus letter callback

* feat: removing flip

* feat: adding bonus letters to board

* Apply suggestions from code review

Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com>

* feat: improving bonus letters grouping

* feat: removing not needed pump

* feat: rebase fix

* fix: re adding wrongly removed code during rebase

* fix: lint

* fix: flaky test

* Apply suggestions from code review

Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com>

Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com>
pull/41/head
Erick 4 years ago committed by GitHub
parent f6f171b7c7
commit 03d48eac40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -49,6 +49,10 @@ class GameState extends Equatable {
/// Determines when the player has only one ball left. /// Determines when the player has only one ball left.
bool get isLastBall => balls == 1; bool get isLastBall => balls == 1;
/// Shortcut method to check if the given [i]
/// is activated.
bool isLetterActivated(int i) => activatedBonusLetters.contains(i);
GameState copyWith({ GameState copyWith({
int? score, int? score,
int? balls, int? balls,

@ -0,0 +1,134 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart';
/// {@template bonus_word}
/// Loads all [BonusLetter]s to compose a [BonusWord].
/// {@endtemplate}
class BonusWord extends Component {
/// {@macro bonus_word}
BonusWord({required Vector2 position}) : _position = position;
final Vector2 _position;
@override
Future<void> 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<PinballGame>
with BlocComponent<GameBloc, GameState> {
/// {@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<void> 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<GameBloc>().add(BonusLetterActivated(_index));
}
}
}
/// Triggers [BonusLetter.activate] method when a [BonusLetter] and a [Ball]
/// come in contact.
class BonusLetterBallContactCallback
extends ContactCallback<Ball, BonusLetter> {
@override
void begin(Ball ball, BonusLetter bonusLetter, Contact contact) {
bonusLetter.activate();
}
}

@ -2,6 +2,7 @@ export 'anchor.dart';
export 'ball.dart'; export 'ball.dart';
export 'baseboard.dart'; export 'baseboard.dart';
export 'board_side.dart'; export 'board_side.dart';
export 'bonus_word.dart';
export 'flipper.dart'; export 'flipper.dart';
export 'pathway.dart'; export 'pathway.dart';
export 'plunger.dart'; export 'plunger.dart';

@ -48,6 +48,25 @@ class PinballGame extends Forge2DGame
), ),
); );
unawaited(_addFlippers());
unawaited(_addBonusWord());
}
Future<void> _addBonusWord() async {
await add(
BonusWord(
position: screenToWorld(
Vector2(
camera.viewport.effectiveSize.x / 2,
camera.viewport.effectiveSize.y - 50,
),
),
),
);
}
Future<void> _addFlippers() async {
final flippersPosition = screenToWorld( final flippersPosition = screenToWorld(
Vector2( Vector2(
camera.viewport.effectiveSize.x / 2, camera.viewport.effectiveSize.x / 2,
@ -73,6 +92,7 @@ class PinballGame extends Forge2DGame
void _addContactCallbacks() { void _addContactCallbacks() {
addContactCallback(BallScorePointsCallback()); addContactCallback(BallScorePointsCallback());
addContactCallback(BottomWallBallContactCallback()); addContactCallback(BottomWallBallContactCallback());
addContactCallback(BonusLetterBallContactCallback());
} }
Future<void> _addGameBoundaries() async { Future<void> _addGameBoundaries() async {

@ -63,6 +63,8 @@ class _PinballGameViewState extends State<PinballGameView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<GameBloc, GameState>( return BlocListener<GameBloc, GameState>(
listenWhen: (previous, current) =>
previous.isGameOver != current.isGameOver,
listener: (context, state) { listener: (context, state) {
if (state.isGameOver) { if (state.isGameOver) {
showDialog<void>( showDialog<void>(

@ -140,21 +140,21 @@ packages:
name: flame name: flame
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.0-releasecandidate.2" version: "1.1.0-releasecandidate.4"
flame_bloc: flame_bloc:
dependency: "direct main" dependency: "direct main"
description: description:
name: flame_bloc name: flame_bloc
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.0-releasecandidate.2" version: "1.2.0-releasecandidate.4"
flame_forge2d: flame_forge2d:
dependency: "direct main" dependency: "direct main"
description: description:
name: flame_forge2d name: flame_forge2d
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.9.0-releasecandidate.2" version: "0.9.0-releasecandidate.4"
flame_test: flame_test:
dependency: "direct dev" dependency: "direct dev"
description: description:

@ -9,15 +9,15 @@ environment:
dependencies: dependencies:
bloc: ^8.0.2 bloc: ^8.0.2
equatable: ^2.0.3 equatable: ^2.0.3
flame: ^1.1.0-releasecandidate.2 flame: ^1.1.0-releasecandidate.4
flame_bloc: ^1.2.0-releasecandidate.2 flame_bloc: ^1.2.0-releasecandidate.4
flame_forge2d: ^0.9.0-releasecandidate.2 flame_forge2d: ^0.9.0-releasecandidate.4
flutter: flutter:
sdk: flutter sdk: flutter
flutter_bloc: ^8.0.1 flutter_bloc: ^8.0.1
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
geometry: geometry:
path: packages/geometry path: packages/geometry
intl: ^0.17.0 intl: ^0.17.0
pinball_theme: pinball_theme:

@ -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', () { group('copyWith', () {
test( test(
'throws AssertionError ' 'throws AssertionError '

@ -89,9 +89,10 @@ void main() {
}); });
group('resetting a ball', () { group('resetting a ball', () {
final gameBloc = MockGameBloc(); late GameBloc gameBloc;
setUp(() { setUp(() {
gameBloc = MockGameBloc();
whenListen( whenListen(
gameBloc, gameBloc,
const Stream<GameState>.empty(), const Stream<GameState>.empty(),
@ -99,7 +100,7 @@ void main() {
); );
}); });
final tester = flameBlocTester(gameBloc: gameBloc); final tester = flameBlocTester(gameBloc: () => gameBloc);
tester.widgetTest( tester.widgetTest(
'adds BallLost to GameBloc', 'adds BallLost to GameBloc',
@ -118,7 +119,7 @@ void main() {
(game, tester) async { (game, tester) async {
await game.ready(); await game.ready();
game.children.whereType<Ball>().first.removeFromParent(); game.children.whereType<Ball>().first.lost();
await game.ready(); // Making sure that all additions are done await game.ready(); // Making sure that all additions are done
expect( expect(

@ -0,0 +1,244 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/effects.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart';
import '../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('BonusWord', () {
final flameTester = FlameTester(PinballGameTest.create);
flameTester.test(
'loads the letters correctly',
(game) async {
await game.ready();
final bonusWord = game.children.whereType<BonusWord>().first;
final letters = bonusWord.children.whereType<BonusLetter>();
expect(letters.length, equals(GameBloc.bonusWord.length));
},
);
});
group('BonusLetter', () {
final flameTester = FlameTester(PinballGameTest.create);
flameTester.test(
'loads correctly',
(game) async {
final bonusLetter = BonusLetter(
position: Vector2.zero(),
letter: 'G',
index: 0,
);
await game.ensureAdd(bonusLetter);
await game.ready();
expect(game.contains(bonusLetter), isTrue);
},
);
group('body', () {
flameTester.test(
'positions correctly',
(game) async {
final position = Vector2.all(10);
final bonusLetter = BonusLetter(
position: position,
letter: 'G',
index: 0,
);
await game.ensureAdd(bonusLetter);
game.contains(bonusLetter);
expect(bonusLetter.body.position, position);
},
);
flameTester.test(
'is static',
(game) async {
final bonusLetter = BonusLetter(
position: Vector2.zero(),
letter: 'G',
index: 0,
);
await game.ensureAdd(bonusLetter);
expect(bonusLetter.body.bodyType, equals(BodyType.static));
},
);
});
group('fixture', () {
flameTester.test(
'exists',
(game) async {
final bonusLetter = BonusLetter(
position: Vector2.zero(),
letter: 'G',
index: 0,
);
await game.ensureAdd(bonusLetter);
expect(bonusLetter.body.fixtures[0], isA<Fixture>());
},
);
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<BonusWord>()
.first
.children
.whereType<BonusLetter>()
.first;
}
setUp(() {
whenListen(
gameBloc,
const Stream<GameState>.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<ColorEffect>().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);
});
});
});
}

@ -227,7 +227,7 @@ void main() {
); );
}); });
final flameTester = flameBlocTester(gameBloc: gameBloc); final flameTester = flameBlocTester(gameBloc: () => gameBloc);
group('initializes with', () { group('initializes with', () {
flameTester.test( flameTester.test(

@ -94,7 +94,7 @@ void main() {
whenListen( whenListen(
gameBloc, gameBloc,
Stream.value(state), Stream.value(state),
initialState: state, initialState: GameState.initial(),
); );
await tester.pumpApp( await tester.pumpApp(

@ -5,14 +5,14 @@ import 'package:pinball/game/game.dart';
import 'helpers.dart'; import 'helpers.dart';
FlameTester<PinballGame> flameBlocTester({ FlameTester<PinballGame> flameBlocTester({
required GameBloc gameBloc, required GameBloc Function() gameBloc,
}) { }) {
return FlameTester<PinballGame>( return FlameTester<PinballGame>(
PinballGameTest.create, PinballGameTest.create,
pumpWidget: (gameWidget, tester) async { pumpWidget: (gameWidget, tester) async {
await tester.pumpWidget( await tester.pumpWidget(
BlocProvider.value( BlocProvider.value(
value: gameBloc, value: gameBloc(),
child: gameWidget, child: gameWidget,
), ),
); );

@ -37,3 +37,5 @@ class MockRawKeyUpEvent extends Mock implements RawKeyUpEvent {
class MockTapUpInfo extends Mock implements TapUpInfo {} class MockTapUpInfo extends Mock implements TapUpInfo {}
class MockEventPosition extends Mock implements EventPosition {} class MockEventPosition extends Mock implements EventPosition {}
class MockBonusLetter extends Mock implements BonusLetter {}

Loading…
Cancel
Save