Merge branch 'main' into feat/character-theming

pull/11/head
Allison Ryan 3 years ago
commit f802e592f5

@ -26,6 +26,9 @@ class GameState extends Equatable {
/// 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,

@ -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);
}
}

@ -28,4 +28,15 @@ class Ball extends BodyComponent<PinballGame>
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
void lost() {
shouldRemove = true;
final bloc = gameRef.read<GameBloc>()..add(const BallLost());
final shouldBallRespwan = !bloc.state.isLastBall;
if (shouldBallRespwan) {
gameRef.spawnBall();
}
}
}

@ -1,2 +1,4 @@
export 'anchor.dart';
export 'ball.dart';
export 'score_points.dart';
export 'wall.dart';

@ -17,10 +17,10 @@ class BallScorePointsCallback extends ContactCallback<Ball, ScorePoints> {
@override
void begin(
Ball ball,
ScorePoints hasPoints,
ScorePoints scorePoints,
Contact _,
) {
ball.gameRef.read<GameBloc>().add(Scored(points: hasPoints.points));
ball.gameRef.read<GameBloc>().add(Scored(points: scorePoints.points));
}
// TODO(alestiago): remove once this issue is closed.

@ -0,0 +1,51 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/components/components.dart';
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);
}
}
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,
),
);
}
class BottomWallBallContactCallback extends ContactCallback<Ball, BottomWall> {
@override
void begin(Ball ball, BottomWall wall, Contact contact) {
ball.lost();
}
@override
void end(_, __, ___) {}
}

@ -1,4 +1,4 @@
export 'bloc/game_bloc.dart';
export 'components/components.dart';
export 'pinball_game.dart';
export 'view/pinball_game_page.dart';
export 'view/view.dart';

@ -1,10 +1,27 @@
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<void> onLoad() async {
spawnBall();
addContactCallback(BallScorePointsCallback());
await add(BottomWall(this));
addContactCallback(BottomWallBallContactCallback());
}
}

@ -1,16 +1,45 @@
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<void>(builder: (_) => const PinballGamePage());
return MaterialPageRoute<void>(
builder: (_) {
return BlocProvider(
create: (_) => GameBloc(),
child: const PinballGamePage(),
);
},
);
}
@override
Widget build(BuildContext context) {
return GameWidget(game: PinballGame());
return const PinballGameView();
}
}
class PinballGameView extends StatelessWidget {
const PinballGameView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocListener<GameBloc, GameState>(
listener: (context, state) {
if (state.isGameOver) {
showDialog<void>(
context: context,
builder: (_) {
return const GameOverDialog();
},
);
}
},
child: GameWidget<PinballGame>(game: PinballGame()),
);
}
}

@ -0,0 +1,2 @@
export 'pinball_game_page.dart';
export 'widgets/widgets.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'),
),
),
);
}
}

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

@ -62,6 +62,32 @@ void main() {
});
});
group('isLastBall', () {
test(
'is true '
'when there is only one ball left',
() {
const gameState = GameState(
balls: 1,
score: 0,
);
expect(gameState.isLastBall, isTrue);
},
);
test(
'is false '
'when there are more balls left',
() {
const gameState = GameState(
balls: 2,
score: 0,
);
expect(gameState.isLastBall, isFalse);
},
);
});
group('copyWith', () {
test(
'throws AssertionError '

@ -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);
},
);
});
});
}

@ -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();
@ -79,5 +83,71 @@ void main() {
},
);
});
group('resetting a ball', () {
late GameBloc gameBloc;
setUp(() {
gameBloc = MockGameBloc();
whenListen(
gameBloc,
const Stream<GameState>.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<Ball>().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<Ball>().first.removeFromParent();
await game.ready(); // Making sure that all additions are done
expect(
game.children.whereType<Ball>().length,
equals(1),
);
},
);
tester.widgetTest(
'no ball is added on game over',
(game, tester) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState(score: 10, balls: 1),
);
await game.ready();
game.children.whereType<Ball>().first.removeFromParent();
await tester.pump();
expect(
game.children.whereType<Ball>().length,
equals(0),
);
},
);
});
});
}

@ -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<Fixture>());
},
);
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));
},
);
});
});
}

@ -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,80 @@ 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<PinballGame>), 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<void>(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', (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<PinballGame>),
findsOneWidget,
);
});
testWidgets(
'renders a game over dialog when the user has lost',
(tester) async {
final gameBloc = MockGameBloc();
const state = GameState(score: 0, balls: 0);
whenListen(
gameBloc,
Stream.value(state),
initialState: state,
);
await tester.pumpApp(const PinballGameView(), gameBloc: gameBloc);
await tester.pump();
expect(
find.text('Game Over'),
findsOneWidget,
);
},
);
});
}

@ -0,0 +1,17 @@
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,
),
);
},
);
}

@ -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';

@ -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 {}

@ -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<void> 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,
),
),
);
}

Loading…
Cancel
Save