mirror of https://github.com/flutter/pinball.git
commit
6cf2ccb5e0
@ -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<GameEvent, GameState> {
|
||||
GameBloc() : super(const GameState.initial()) {
|
||||
on<BallLost>(_onBallLost);
|
||||
on<Scored>(_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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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<Object?> 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<Object?> get props => [points];
|
||||
}
|
@ -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<Object?> get props => [
|
||||
score,
|
||||
balls,
|
||||
];
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import 'package:flame_bloc/flame_bloc.dart';
|
||||
import 'package:flame_forge2d/body_component.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forge2d/forge2d.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
|
||||
class Ball extends BodyComponent<PinballGame>
|
||||
with BlocComponent<GameBloc, GameState> {
|
||||
Ball({
|
||||
required Vector2 position,
|
||||
}) : _position = position {
|
||||
// TODO(alestiago): Use asset instead of color when provided.
|
||||
paint = Paint()..color = const Color(0xFFFFFFFF);
|
||||
}
|
||||
|
||||
final Vector2 _position;
|
||||
|
||||
@override
|
||||
Body createBody() {
|
||||
final shape = CircleShape()..radius = 2;
|
||||
|
||||
final fixtureDef = FixtureDef(shape)..density = 1;
|
||||
|
||||
final bodyDef = BodyDef()
|
||||
..userData = this
|
||||
..position = _position
|
||||
..type = BodyType.dynamic;
|
||||
|
||||
return world.createBody(bodyDef)..createFixture(fixtureDef);
|
||||
}
|
||||
}
|
@ -1,2 +1,4 @@
|
||||
export 'ball.dart';
|
||||
export 'boundaries.dart';
|
||||
export 'plunger.dart';
|
||||
export 'score_points.dart';
|
||||
|
@ -0,0 +1,30 @@
|
||||
// ignore_for_file: avoid_renaming_method_parameters
|
||||
|
||||
import 'package:flame_forge2d/flame_forge2d.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
|
||||
/// {@template score_points}
|
||||
/// Specifies the amount of points received on [Ball] collision.
|
||||
/// {@endtemplate}
|
||||
mixin ScorePoints on BodyComponent {
|
||||
/// {@macro score_points}
|
||||
int get points;
|
||||
}
|
||||
|
||||
/// Adds points to the score when a [Ball] collides with a [BodyComponent] that
|
||||
/// implements [ScorePoints].
|
||||
class BallScorePointsCallback extends ContactCallback<Ball, ScorePoints> {
|
||||
@override
|
||||
void begin(
|
||||
Ball ball,
|
||||
ScorePoints hasPoints,
|
||||
Contact _,
|
||||
) {
|
||||
ball.gameRef.read<GameBloc>().add(Scored(points: hasPoints.points));
|
||||
}
|
||||
|
||||
// TODO(alestiago): remove once this issue is closed.
|
||||
// https://github.com/flame-engine/flame/issues/1414
|
||||
@override
|
||||
void end(Ball _, ScorePoints __, Contact ___) {}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
export 'bloc/game_bloc.dart';
|
||||
export 'components/components.dart';
|
||||
export 'pinball_game.dart';
|
||||
export 'view/pinball_game_page.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<GameBloc, GameState>(
|
||||
"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<GameBloc, GameState>(
|
||||
'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<GameBloc, GameState>(
|
||||
"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),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
// 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('Ball', () {
|
||||
final flameTester = FlameTester(PinballGame.new);
|
||||
|
||||
flameTester.test(
|
||||
'loads correctly',
|
||||
(game) async {
|
||||
final ball = Ball(position: Vector2.zero());
|
||||
await game.ensureAdd(ball);
|
||||
|
||||
expect(game.contains(ball), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
group('body', () {
|
||||
flameTester.test(
|
||||
'positions correctly',
|
||||
(game) async {
|
||||
final position = Vector2.all(10);
|
||||
final ball = Ball(position: position);
|
||||
await game.ensureAdd(ball);
|
||||
game.contains(ball);
|
||||
|
||||
expect(ball.body.position, position);
|
||||
},
|
||||
);
|
||||
|
||||
flameTester.test(
|
||||
'is dynamic',
|
||||
(game) async {
|
||||
final ball = Ball(position: Vector2.zero());
|
||||
await game.ensureAdd(ball);
|
||||
|
||||
expect(ball.body.bodyType, equals(BodyType.dynamic));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('first fixture', () {
|
||||
flameTester.test(
|
||||
'exists',
|
||||
(game) async {
|
||||
final ball = Ball(position: Vector2.zero());
|
||||
await game.ensureAdd(ball);
|
||||
|
||||
expect(ball.body.fixtures[0], isA<Fixture>());
|
||||
},
|
||||
);
|
||||
|
||||
flameTester.test(
|
||||
'is dense',
|
||||
(game) async {
|
||||
final ball = Ball(position: Vector2.zero());
|
||||
await game.ensureAdd(ball);
|
||||
|
||||
final fixture = ball.body.fixtures[0];
|
||||
expect(fixture.density, greaterThan(0));
|
||||
},
|
||||
);
|
||||
|
||||
flameTester.test(
|
||||
'shape is circular',
|
||||
(game) async {
|
||||
final ball = Ball(position: Vector2.zero());
|
||||
await game.ensureAdd(ball);
|
||||
|
||||
final fixture = ball.body.fixtures[0];
|
||||
expect(fixture.shape.shapeType, equals(ShapeType.circle));
|
||||
expect(fixture.shape.radius, equals(2));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
import 'package:flame_forge2d/flame_forge2d.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
|
||||
class MockBall extends Mock implements Ball {}
|
||||
|
||||
class MockGameBloc extends Mock implements GameBloc {}
|
||||
|
||||
class MockPinballGame extends Mock implements PinballGame {}
|
||||
|
||||
class FakeContact extends Fake implements Contact {}
|
||||
|
||||
class FakeGameEvent extends Fake implements GameEvent {}
|
||||
|
||||
class FakeScorePoints extends BodyComponent with ScorePoints {
|
||||
@override
|
||||
Body createBody() {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
int get points => 2;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('BallScorePointsCallback', () {
|
||||
late PinballGame game;
|
||||
late GameBloc bloc;
|
||||
late Ball ball;
|
||||
late FakeScorePoints fakeScorePoints;
|
||||
|
||||
setUp(() {
|
||||
game = MockPinballGame();
|
||||
bloc = MockGameBloc();
|
||||
ball = MockBall();
|
||||
fakeScorePoints = FakeScorePoints();
|
||||
});
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeGameEvent());
|
||||
});
|
||||
|
||||
group('begin', () {
|
||||
test(
|
||||
'emits Scored event with points',
|
||||
() {
|
||||
when<PinballGame>(() => ball.gameRef).thenReturn(game);
|
||||
when<GameBloc>(game.read).thenReturn(bloc);
|
||||
|
||||
BallScorePointsCallback().begin(
|
||||
ball,
|
||||
fakeScorePoints,
|
||||
FakeContact(),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => bloc.add(
|
||||
Scored(points: fakeScorePoints.points),
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('end', () {
|
||||
test("doesn't add events to GameBloc", () {
|
||||
BallScorePointsCallback().end(
|
||||
ball,
|
||||
fakeScorePoints,
|
||||
FakeContact(),
|
||||
);
|
||||
|
||||
verifyNever(
|
||||
() => bloc.add(any()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('PinballGame', () {
|
||||
// TODO(alestiago): test if [PinballGame] registers
|
||||
// [BallScorePointsCallback] once the following issue is resolved:
|
||||
// https://github.com/flame-engine/flame/issues/1416
|
||||
});
|
||||
}
|
Loading…
Reference in new issue