refactor: removed `GameBallsController` (#349)

pull/353/head
Alejandro Santiago 3 years ago committed by GitHub
parent 28606241df
commit ace61193fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,33 @@
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// Spawns a new [Ball] into the game when all balls are lost and still
/// [GameStatus.playing].
class BallSpawningBehavior extends Component
with ParentIsA<PinballGame>, BlocComponent<GameBloc, GameState> {
@override
bool listenWhen(GameState? previousState, GameState newState) {
if (!newState.status.isPlaying) return false;
final startedGame = previousState?.status.isWaiting ?? true;
final lostRound =
(previousState?.rounds ?? newState.rounds + 1) > newState.rounds;
return startedGame || lostRound;
}
@override
void onNewState(GameState state) {
final plunger = parent.descendants().whereType<Plunger>().single;
final canvas = parent.descendants().whereType<ZCanvasComponent>().single;
final ball = ControlledBall.launch(characterTheme: parent.characterTheme)
..initialPosition = Vector2(
plunger.body.position.x,
plunger.body.position.y - Ball.size.y,
);
canvas.add(ball);
}
}

@ -1,3 +1,4 @@
export 'ball_spawning_behavior.dart';
export 'bumper_noisy_behavior.dart';
export 'camera_focusing_behavior.dart';
export 'scoring_behavior.dart';

@ -27,6 +27,7 @@ enum GameStatus {
}
extension GameStatusX on GameStatus {
bool get isWaiting => this == GameStatus.waiting;
bool get isPlaying => this == GameStatus.playing;
bool get isGameOver => this == GameStatus.gameOver;
}

@ -5,7 +5,7 @@ export 'controlled_ball.dart';
export 'controlled_flipper.dart';
export 'controlled_plunger.dart';
export 'dino_desert/dino_desert.dart';
export 'drain.dart';
export 'drain/drain.dart';
export 'flutter_forest/flutter_forest.dart';
export 'game_bloc_status_listener.dart';
export 'google_word/google_word.dart';

@ -22,9 +22,7 @@ class ControlledBall extends Ball with Controls<BallController> {
zIndex = ZIndexes.ballOnLaunchRamp;
}
/// {@template bonus_ball}
/// {@macro controlled_ball}
/// {@endtemplate}
ControlledBall.bonus({
required CharacterTheme characterTheme,
}) : super(assetPath: characterTheme.ball.keyName) {
@ -47,12 +45,6 @@ class BallController extends ComponentController<Ball>
/// {@macro ball_controller}
BallController(Ball ball) : super(ball);
/// Event triggered when the ball is lost.
// TODO(alestiago): Refactor using behaviors.
void lost() {
component.shouldRemove = true;
}
/// Stops the [Ball] inside of the [SparkyComputer] while the turbo charge
/// sequence runs, then boosts the ball out of the computer.
Future<void> turboCharge() async {
@ -70,13 +62,4 @@ class BallController extends ComponentController<Ball>
BallTurboChargingBehavior(impulse: Vector2(40, 110)),
);
}
@override
void onRemove() {
super.onRemove();
final noBallsLeft = gameRef.descendants().whereType<Ball>().isEmpty;
if (noBallsLeft) {
gameRef.read<GameBloc>().add(const RoundLost());
}
}
}

@ -1,34 +0,0 @@
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template drain}
/// Area located at the bottom of the board to detect when a [Ball] is lost.
/// {@endtemplate}
// TODO(allisonryan0002): move to components package when possible.
class Drain extends BodyComponent with ContactCallbacks {
/// {@macro drain}
Drain() : super(renderBody: false);
@override
Body createBody() {
final shape = EdgeShape()
..set(
BoardDimensions.bounds.bottomLeft.toVector2(),
BoardDimensions.bounds.bottomRight.toVector2(),
);
final fixtureDef = FixtureDef(shape, isSensor: true);
final bodyDef = BodyDef(userData: this);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
// TODO(allisonryan0002): move this to ball.dart when BallLost is removed.
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! ControlledBall) return;
other.controller.lost();
}
}

@ -0,0 +1 @@
export 'draining_behavior.dart';

@ -0,0 +1,21 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// Handles removing a [Ball] from the game.
class DrainingBehavior extends ContactBehavior<Drain>
with HasGameRef<PinballGame> {
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! Ball) return;
other.removeFromParent();
final ballsLeft = gameRef.descendants().whereType<Ball>().length;
if (ballsLeft - 1 == 0) {
gameRef.read<GameBloc>().add(const RoundLost());
}
}
}

@ -0,0 +1,36 @@
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/foundation.dart';
import 'package:pinball/game/components/drain/behaviors/behaviors.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template drain}
/// Area located at the bottom of the board.
///
/// Its [DrainingBehavior] handles removing a [Ball] from the game.
/// {@endtemplate}
class Drain extends BodyComponent with ContactCallbacks {
/// {@macro drain}
Drain()
: super(
renderBody: false,
children: [DrainingBehavior()],
);
/// Creates a [Drain] without any children.
///
/// This can be used for testing a [Drain]'s behaviors in isolation.
@visibleForTesting
Drain.test();
@override
Body createBody() {
final shape = EdgeShape()
..set(
BoardDimensions.bounds.bottomLeft.toVector2(),
BoardDimensions.bounds.bottomRight.toVector2(),
);
final fixtureDef = FixtureDef(shape, isSensor: true);
return world.createBody(BodyDef())..createFixture(fixtureDef);
}
}

@ -17,11 +17,7 @@ import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart';
class PinballGame extends PinballForge2DGame
with
FlameBloc,
HasKeyboardHandlerComponents,
Controls<_GameBallsController>,
MultiTouchTapDetector {
with FlameBloc, HasKeyboardHandlerComponents, MultiTouchTapDetector {
PinballGame({
required this.characterTheme,
required this.leaderboardRepository,
@ -29,7 +25,6 @@ class PinballGame extends PinballForge2DGame
required this.player,
}) : super(gravity: Vector2(0, 30)) {
images.prefix = '';
controller = _GameBallsController(this);
}
/// Identifier of the play button overlay
@ -73,6 +68,7 @@ class PinballGame extends PinballForge2DGame
await addAll(
[
GameBlocStatusListener(),
BallSpawningBehavior(),
CameraFocusingBehavior(),
CanvasComponent(
onSpritePainted: (paint) {
@ -147,43 +143,6 @@ class PinballGame extends PinballForge2DGame
}
}
class _GameBallsController extends ComponentController<PinballGame>
with BlocComponent<GameBloc, GameState> {
_GameBallsController(PinballGame game) : super(game);
@override
bool listenWhen(GameState? previousState, GameState newState) {
final noBallsLeft = component.descendants().whereType<Ball>().isEmpty;
return noBallsLeft && newState.status.isPlaying;
}
@override
void onNewState(GameState state) {
super.onNewState(state);
spawnBall();
}
@override
Future<void> onLoad() async {
await super.onLoad();
spawnBall();
}
void spawnBall() {
// TODO(alestiago): Refactor with behavioural pattern.
component.ready().whenComplete(() {
final plunger = parent!.descendants().whereType<Plunger>().single;
final ball = ControlledBall.launch(
characterTheme: component.characterTheme,
)..initialPosition = Vector2(
plunger.body.position.x,
plunger.body.position.y - Ball.size.y,
);
component.descendants().whereType<ZCanvasComponent>().single.add(ball);
});
}
}
class DebugPinballGame extends PinballGame with FPSCounter, PanDetector {
DebugPinballGame({
required CharacterTheme characterTheme,
@ -195,9 +154,7 @@ class DebugPinballGame extends PinballGame with FPSCounter, PanDetector {
player: player,
leaderboardRepository: leaderboardRepository,
l10n: l10n,
) {
controller = _GameBallsController(this);
}
);
Vector2? lineStart;
Vector2? lineEnd;

@ -1,5 +1,6 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
@ -13,16 +14,23 @@ class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex {
/// {@macro plunger}
Plunger({
required this.compressionDistance,
}) : super(renderBody: false) {
}) : super(
renderBody: false,
children: [_PlungerSpriteAnimationGroupComponent()],
) {
zIndex = ZIndexes.plunger;
layer = Layer.launcher;
}
/// Creates a [Plunger] without any children.
///
/// This can be used for testing [Plunger]'s behaviors in isolation.
@visibleForTesting
Plunger.test({required this.compressionDistance});
/// Distance the plunger can lower.
final double compressionDistance;
late final _PlungerSpriteAnimationGroupComponent _spriteComponent;
List<FixtureDef> _createFixtureDefs() {
final fixturesDef = <FixtureDef>[];
@ -78,8 +86,10 @@ class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex {
/// Set a constant downward velocity on the [Plunger].
void pull() {
final sprite = firstChild<_PlungerSpriteAnimationGroupComponent>()!;
body.linearVelocity = Vector2(0, 7);
_spriteComponent.pull();
sprite.pull();
}
/// Set an upward velocity on the [Plunger].
@ -87,10 +97,12 @@ class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex {
/// The velocity's magnitude depends on how far the [Plunger] has been pulled
/// from its original [initialPosition].
void release() {
final sprite = firstChild<_PlungerSpriteAnimationGroupComponent>()!;
_pullingDownTime = 0;
final velocity = (initialPosition.y - body.position.y) * 11;
body.linearVelocity = Vector2(0, velocity);
_spriteComponent.release();
sprite.release();
}
@override
@ -127,9 +139,6 @@ class Plunger extends BodyComponent with InitialPosition, Layered, ZIndex {
Future<void> onLoad() async {
await super.onLoad();
await _anchorToJoint();
_spriteComponent = _PlungerSpriteAnimationGroupComponent();
await add(_spriteComponent);
}
}

@ -14,6 +14,17 @@ void main() {
group('Plunger', () {
const compressionDistance = 0.0;
test('can be instantiated', () {
expect(
Plunger(compressionDistance: compressionDistance),
isA<Plunger>(),
);
expect(
Plunger.test(compressionDistance: compressionDistance),
isA<Plunger>(),
);
});
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {

@ -0,0 +1,117 @@
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/behaviors/ball_spawning_behavior.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme;
import '../../../../helpers/test_games.dart';
class _MockGameState extends Mock implements GameState {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group(
'BallSpawningBehavior',
() {
final flameTester = FlameTester(EmptyPinballTestGame.new);
test('can be instantiated', () {
expect(
BallSpawningBehavior(),
isA<BallSpawningBehavior>(),
);
});
flameTester.test(
'loads',
(game) async {
final behavior = BallSpawningBehavior();
await game.ensureAdd(behavior);
expect(game.contains(behavior), isTrue);
},
);
group('listenWhen', () {
test(
'never listens when new state not playing',
() {
final waiting = const GameState.initial()
..copyWith(status: GameStatus.waiting);
final gameOver = const GameState.initial()
..copyWith(status: GameStatus.gameOver);
final behavior = BallSpawningBehavior();
expect(behavior.listenWhen(_MockGameState(), waiting), isFalse);
expect(behavior.listenWhen(_MockGameState(), gameOver), isFalse);
},
);
test(
'listens when started playing',
() {
final waiting =
const GameState.initial().copyWith(status: GameStatus.waiting);
final playing =
const GameState.initial().copyWith(status: GameStatus.playing);
final behavior = BallSpawningBehavior();
expect(behavior.listenWhen(waiting, playing), isTrue);
},
);
test(
'listens when lost rounds',
() {
final playing1 = const GameState.initial().copyWith(
status: GameStatus.playing,
rounds: 2,
);
final playing2 = const GameState.initial().copyWith(
status: GameStatus.playing,
rounds: 1,
);
final behavior = BallSpawningBehavior();
expect(behavior.listenWhen(playing1, playing2), isTrue);
},
);
test(
"doesn't listen when didn't lose any rounds",
() {
final playing = const GameState.initial().copyWith(
status: GameStatus.playing,
rounds: 2,
);
final behavior = BallSpawningBehavior();
expect(behavior.listenWhen(playing, playing), isFalse);
},
);
});
flameTester.test(
'onNewState adds a ball',
(game) async {
await game.images.load(theme.Assets.images.dash.ball.keyName);
final behavior = BallSpawningBehavior();
await game.ensureAddAll([
behavior,
ZCanvasComponent(),
Plunger.test(compressionDistance: 10),
]);
expect(game.descendants().whereType<Ball>(), isEmpty);
behavior.onNewState(_MockGameState());
await game.ready();
expect(game.descendants().whereType<Ball>(), isNotEmpty);
},
);
},
);
}

@ -63,43 +63,6 @@ void main() {
);
});
flameBlocTester.testGameWidget(
"lost doesn't adds RoundLost to GameBloc "
'when there are balls left',
setUp: (game, tester) async {
final controller = BallController(ball);
await ball.add(controller);
await game.ensureAdd(ball);
final otherBall = Ball();
final otherController = BallController(otherBall);
await otherBall.add(otherController);
await game.ensureAdd(otherBall);
controller.lost();
await game.ready();
},
verify: (game, tester) async {
verifyNever(() => gameBloc.add(const RoundLost()));
},
);
flameBlocTester.testGameWidget(
'lost adds RoundLost to GameBloc '
'when there are no balls left',
setUp: (game, tester) async {
final controller = BallController(ball);
await ball.add(controller);
await game.ensureAdd(ball);
controller.lost();
await game.ready();
},
verify: (game, tester) async {
verify(() => gameBloc.add(const RoundLost())).called(1);
},
);
group('turboCharge', () {
setUpAll(() {
registerFallbackValue(Vector2.zero());

@ -0,0 +1,121 @@
// 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/components/drain/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme;
import '../../../../helpers/helpers.dart';
class _MockGameBloc extends Mock implements GameBloc {}
class _MockContact extends Mock implements Contact {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group(
'DrainingBehavior',
() {
final flameTester = FlameTester(Forge2DGame.new);
test('can be instantiated', () {
expect(DrainingBehavior(), isA<DrainingBehavior>());
});
flameTester.test(
'loads',
(game) async {
final parent = Drain.test();
final behavior = DrainingBehavior();
await parent.add(behavior);
await game.ensureAdd(parent);
expect(parent.contains(behavior), isTrue);
},
);
group('beginContact', () {
final asset = theme.Assets.images.dash.ball.keyName;
late GameBloc gameBloc;
setUp(() {
gameBloc = _MockGameBloc();
whenListen(
gameBloc,
const Stream<GameState>.empty(),
);
});
final flameBlocTester = FlameBlocTester<PinballGame, GameBloc>(
gameBuilder: EmptyPinballTestGame.new,
blocBuilder: () => gameBloc,
);
flameBlocTester.testGameWidget(
'adds RoundLost when no balls left',
setUp: (game, tester) async {
await game.images.load(asset);
final drain = Drain.test();
final behavior = DrainingBehavior();
final ball = Ball.test();
await drain.add(behavior);
await game.ensureAddAll([drain, ball]);
behavior.beginContact(ball, _MockContact());
await game.ready();
expect(game.descendants().whereType<Ball>(), isEmpty);
verify(() => gameBloc.add(const RoundLost())).called(1);
},
);
flameBlocTester.testGameWidget(
"doesn't add RoundLost when there are balls left",
setUp: (game, tester) async {
await game.images.load(asset);
final drain = Drain.test();
final behavior = DrainingBehavior();
final ball1 = Ball.test();
final ball2 = Ball.test();
await drain.add(behavior);
await game.ensureAddAll([
drain,
ball1,
ball2,
]);
behavior.beginContact(ball1, _MockContact());
await game.ready();
expect(game.descendants().whereType<Ball>(), isNotEmpty);
verifyNever(() => gameBloc.add(const RoundLost()));
},
);
flameBlocTester.testGameWidget(
'removes the Ball',
setUp: (game, tester) async {
await game.images.load(asset);
final drain = Drain.test();
final behavior = DrainingBehavior();
final ball = Ball.test();
await drain.add(behavior);
await game.ensureAddAll([drain, ball]);
behavior.beginContact(ball, _MockContact());
await game.ready();
expect(game.descendants().whereType<Ball>(), isEmpty);
},
);
});
},
);
}

@ -3,16 +3,10 @@
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';
class _MockControlledBall extends Mock implements ControlledBall {}
class _MockBallController extends Mock implements BallController {}
import 'package:pinball/game/game.dart';
class _MockContact extends Mock implements Contact {}
import '../../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -45,19 +39,5 @@ void main() {
expect(drain.body.fixtures.first.isSensor, isTrue);
},
);
test(
'calls lost on contact with ball',
() async {
final drain = Drain();
final ball = _MockControlledBall();
final controller = _MockBallController();
when(() => ball.controller).thenReturn(controller);
drain.beginContact(ball, _MockContact());
verify(controller.lost).called(1);
},
);
});
}

@ -9,6 +9,7 @@ import 'package:flame_test/flame_test.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme;
@ -17,8 +18,6 @@ import '../helpers/helpers.dart';
class _MockGameBloc extends Mock implements GameBloc {}
class _MockGameState extends Mock implements GameState {}
class _MockEventPosition extends Mock implements EventPosition {}
class _MockTapDownDetails extends Mock implements TapDownDetails {}
@ -167,8 +166,17 @@ void main() {
);
group('components', () {
// TODO(alestiago): tests that Blueprints get added once the Blueprint
// class is removed.
flameBlocTester.test(
'has only one BallSpawningBehavior',
(game) async {
await game.ready();
expect(
game.descendants().whereType<BallSpawningBehavior>().length,
equals(1),
);
},
);
flameBlocTester.test(
'has only one Drain',
(game) async {
@ -272,91 +280,6 @@ void main() {
}
},
);
group('controller', () {
group('listenWhen', () {
flameTester.testGameWidget(
'listens when all balls are lost and there are more than 0 rounds',
setUp: (game, tester) async {
// TODO(ruimiguel): check why testGameWidget doesn't add any ball
// to the game. Test needs to have no balls, so fortunately works.
final newState = _MockGameState();
when(() => newState.status).thenReturn(GameStatus.playing);
game.descendants().whereType<ControlledBall>().forEach(
(ball) => ball.controller.lost(),
);
await game.ready();
expect(
game.controller.listenWhen(_MockGameState(), newState),
isTrue,
);
},
);
flameTester.test(
"doesn't listen when some balls are left",
(game) async {
final newState = _MockGameState();
when(() => newState.status).thenReturn(GameStatus.playing);
await game.ready();
expect(
game.descendants().whereType<ControlledBall>().length,
greaterThan(0),
);
expect(
game.controller.listenWhen(_MockGameState(), newState),
isFalse,
);
},
);
flameTester.testGameWidget(
"doesn't listen when game is over",
setUp: (game, tester) async {
// TODO(ruimiguel): check why testGameWidget doesn't add any ball
// to the game. Test needs to have no balls, so fortunately works.
final newState = _MockGameState();
when(() => newState.status).thenReturn(GameStatus.gameOver);
game.descendants().whereType<ControlledBall>().forEach(
(ball) => ball.controller.lost(),
);
await game.ready();
expect(
game.descendants().whereType<ControlledBall>().isEmpty,
isTrue,
);
expect(
game.controller.listenWhen(_MockGameState(), newState),
isFalse,
);
},
);
});
group('onNewState', () {
flameTester.test(
'spawns a ball',
(game) async {
final previousBalls =
game.descendants().whereType<ControlledBall>().toList();
game.controller.onNewState(_MockGameState());
await game.ready();
final currentBalls =
game.descendants().whereType<ControlledBall>().toList();
expect(
currentBalls.length,
equals(previousBalls.length + 1),
);
},
);
});
});
});
group('flipper control', () {

Loading…
Cancel
Save