mirror of https://github.com/flutter/pinball.git
feat: implemented `ComponentController` (#111)
* feat: defined component controller * feat: inclued flame barrel file * feat: implemented ComponentController * feat: implemented PlungerBallController * feat: improved tests * feat: enhanced component_controller * feat: included instantiation test * feat: removed attach method for mixin * docs: improved doc comment * feat: included Controls tests * fix: commented golden testpull/117/head
parent
631c1e9860
commit
79687c8ea3
@ -0,0 +1,37 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame_bloc/flame_bloc.dart';
|
||||
|
||||
/// {@template component_controller}
|
||||
/// A [ComponentController] is a [Component] in charge of handling the logic
|
||||
/// associated with another [Component].
|
||||
///
|
||||
/// [ComponentController]s usually implement [BlocComponent].
|
||||
/// {@endtemplate}
|
||||
abstract class ComponentController<T extends Component> extends Component {
|
||||
/// {@macro component_controller}
|
||||
ComponentController(this.component);
|
||||
|
||||
/// The [Component] controlled by this [ComponentController].
|
||||
final T component;
|
||||
|
||||
@override
|
||||
Future<void> addToParent(Component parent) async {
|
||||
assert(
|
||||
parent == component,
|
||||
'ComponentController should be child of $component.',
|
||||
);
|
||||
await super.addToParent(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixin that attaches a single [ComponentController] to a [Component].
|
||||
mixin Controls<T extends ComponentController> on Component {
|
||||
/// The [ComponentController] attached to this [Component].
|
||||
late final T controller;
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
await super.onLoad();
|
||||
await add(controller);
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export 'component_controller.dart';
|
@ -1,87 +0,0 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
|
||||
/// {@template ball_type}
|
||||
/// Specifies the type of [Ball].
|
||||
///
|
||||
/// Different [BallType]s are affected by different game mechanics.
|
||||
/// {@endtemplate}
|
||||
enum BallType {
|
||||
/// A [Ball] spawned from the [Plunger].
|
||||
///
|
||||
/// [normal] balls decrease the [GameState.balls] when they fall through the
|
||||
/// the [BottomWall].
|
||||
normal,
|
||||
|
||||
/// A [Ball] that does not alter [GameState.balls].
|
||||
///
|
||||
/// For example, a [Ball] spawned by Dash in the [FlutterForest].
|
||||
extra,
|
||||
}
|
||||
|
||||
/// {@template ball_blueprint}
|
||||
/// [Blueprint] which cretes a ball game object.
|
||||
/// {@endtemplate}
|
||||
class BallBlueprint extends Blueprint<PinballGame> {
|
||||
/// {@macro ball_blueprint}
|
||||
BallBlueprint({
|
||||
required this.position,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
/// The initial position of the [Ball].
|
||||
final Vector2 position;
|
||||
|
||||
/// {@macro ball_type}
|
||||
final BallType type;
|
||||
|
||||
@override
|
||||
void build(PinballGame gameRef) {
|
||||
final baseColor = gameRef.theme.characterTheme.ballColor;
|
||||
final ball = Ball(baseColor: baseColor)
|
||||
..add(
|
||||
BallController(type: type),
|
||||
);
|
||||
|
||||
add(ball..initialPosition = position + Vector2(0, ball.size.y / 2));
|
||||
}
|
||||
}
|
||||
|
||||
/// {@template ball_controller}
|
||||
/// Controller attached to a [Ball] that handles its game related logic.
|
||||
/// {@endtemplate}
|
||||
class BallController extends Component with HasGameRef<PinballGame> {
|
||||
/// {@macro ball_controller}
|
||||
BallController({required this.type});
|
||||
|
||||
/// {@macro ball_type}
|
||||
final BallType type;
|
||||
|
||||
/// Removes the [Ball] from a [PinballGame]; spawning a new [Ball] if
|
||||
/// any are left.
|
||||
///
|
||||
/// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into
|
||||
/// a [BottomWall].
|
||||
void lost() {
|
||||
parent?.shouldRemove = true;
|
||||
// TODO(alestiago): Consider adding test for this logic once we remove the
|
||||
// BallX extension.
|
||||
if (type != BallType.normal) return;
|
||||
|
||||
final bloc = gameRef.read<GameBloc>()..add(const BallLost());
|
||||
final shouldBallRespwan = !bloc.state.isLastBall && !bloc.state.isGameOver;
|
||||
if (shouldBallRespwan) {
|
||||
gameRef.spawnBall();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds helper methods to the [Ball]
|
||||
extension BallX on Ball {
|
||||
/// Returns the controller instance of the ball
|
||||
// TODO(erickzanardo): Remove the need of an extension.
|
||||
BallController get controller {
|
||||
return children.whereType<BallController>().first;
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame_forge2d/forge2d_game.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pinball/flame/flame.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
import 'package:pinball_theme/pinball_theme.dart';
|
||||
|
||||
/// {@template controlled_ball}
|
||||
/// A [Ball] with a [BallController] attached.
|
||||
/// {@endtemplate}
|
||||
class ControlledBall extends Ball with Controls<BallController> {
|
||||
/// A [Ball] that launches from the [Plunger].
|
||||
///
|
||||
/// When a launched [Ball] is lost, it will decrease the [GameState.balls]
|
||||
/// count, and a new [Ball] is spawned.
|
||||
ControlledBall.launch({
|
||||
required PinballTheme theme,
|
||||
}) : super(baseColor: theme.characterTheme.ballColor) {
|
||||
controller = LaunchedBallController(this);
|
||||
}
|
||||
|
||||
/// {@template bonus_ball}
|
||||
/// {@macro controlled_ball}
|
||||
///
|
||||
/// When a bonus [Ball] is lost, the [GameState.balls] doesn't change.
|
||||
/// {@endtemplate}
|
||||
ControlledBall.bonus({
|
||||
required PinballTheme theme,
|
||||
}) : super(baseColor: theme.characterTheme.ballColor) {
|
||||
controller = BallController(this);
|
||||
}
|
||||
|
||||
/// [Ball] used in [DebugPinballGame].
|
||||
ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) {
|
||||
controller = BallController(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// {@template ball_controller}
|
||||
/// Controller attached to a [Ball] that handles its game related logic.
|
||||
/// {@endtemplate}
|
||||
class BallController extends ComponentController<Ball> {
|
||||
/// {@macro ball_controller}
|
||||
BallController(Ball ball) : super(ball);
|
||||
|
||||
/// Removes the [Ball] from a [PinballGame].
|
||||
///
|
||||
/// {@template ball_controller_lost}
|
||||
/// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into
|
||||
/// a [BottomWall].
|
||||
/// {@endtemplate}
|
||||
@mustCallSuper
|
||||
void lost() {
|
||||
component.shouldRemove = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// {@macro ball_controller}
|
||||
class LaunchedBallController extends BallController
|
||||
with HasGameRef<PinballGame> {
|
||||
/// {@macro ball_controller}
|
||||
LaunchedBallController(Ball<Forge2DGame> ball) : super(ball);
|
||||
|
||||
/// Removes the [Ball] from a [PinballGame]; spawning a new [Ball] if
|
||||
/// any are left.
|
||||
///
|
||||
/// {@macro ball_controller_lost}
|
||||
@override
|
||||
void lost() {
|
||||
super.lost();
|
||||
|
||||
final bloc = gameRef.read<GameBloc>()..add(const BallLost());
|
||||
|
||||
// TODO(alestiago): Consider the use of onNewState instead.
|
||||
final shouldBallRespwan = !bloc.state.isLastBall && !bloc.state.isGameOver;
|
||||
if (shouldBallRespwan) gameRef.spawnBall();
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
// ignore_for_file: cascade_invocations
|
||||
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flame/src/components/component.dart';
|
||||
import 'package:flame_test/flame_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pinball/flame/flame.dart';
|
||||
|
||||
class TestComponentController extends ComponentController {
|
||||
TestComponentController(Component component) : super(component);
|
||||
}
|
||||
|
||||
class ControlledComponent extends Component
|
||||
with Controls<TestComponentController> {
|
||||
ControlledComponent() : super() {
|
||||
controller = TestComponentController(this);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final flameTester = FlameTester(FlameGame.new);
|
||||
|
||||
group('ComponentController', () {
|
||||
flameTester.test(
|
||||
'can be instantiated',
|
||||
(game) async {
|
||||
expect(
|
||||
TestComponentController(Component()),
|
||||
isA<ComponentController>(),
|
||||
);
|
||||
},
|
||||
);
|
||||
flameTester.test(
|
||||
'throws AssertionError when not attached to controlled component',
|
||||
(game) async {
|
||||
final component = Component();
|
||||
final controller = TestComponentController(component);
|
||||
|
||||
final anotherComponet = Component();
|
||||
await expectLater(
|
||||
() async => await anotherComponet.add(controller),
|
||||
throwsAssertionError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Controls', () {
|
||||
flameTester.test(
|
||||
'can be instantiated',
|
||||
(game) async {
|
||||
expect(ControlledComponent(), isA<Component>());
|
||||
},
|
||||
);
|
||||
|
||||
flameTester.test('adds controller', (game) async {
|
||||
final component = ControlledComponent();
|
||||
|
||||
await game.add(component);
|
||||
await game.ready();
|
||||
|
||||
expect(component.contains(component.controller), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
// ignore_for_file: cascade_invocations
|
||||
|
||||
import 'package:bloc_test/bloc_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();
|
||||
|
||||
group('Ball', () {
|
||||
group('lost', () {
|
||||
late GameBloc gameBloc;
|
||||
|
||||
setUp(() {
|
||||
gameBloc = MockGameBloc();
|
||||
whenListen(
|
||||
gameBloc,
|
||||
const Stream<GameState>.empty(),
|
||||
initialState: const GameState.initial(),
|
||||
);
|
||||
});
|
||||
|
||||
final tester = flameBlocTester(gameBloc: () => gameBloc);
|
||||
|
||||
tester.testGameWidget(
|
||||
'adds BallLost to GameBloc',
|
||||
setUp: (game, tester) async {
|
||||
await game.ready();
|
||||
},
|
||||
verify: (game, tester) async {
|
||||
game.children.whereType<Ball>().first.controller.lost();
|
||||
await tester.pump();
|
||||
|
||||
verify(() => gameBloc.add(const BallLost())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
tester.testGameWidget(
|
||||
'resets the ball if the game is not over',
|
||||
setUp: (game, tester) async {
|
||||
await game.ready();
|
||||
|
||||
game.children.whereType<Ball>().first.controller.lost();
|
||||
await game.ready(); // Making sure that all additions are done
|
||||
},
|
||||
verify: (game, tester) async {
|
||||
expect(
|
||||
game.children.whereType<Ball>().length,
|
||||
equals(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
tester.testGameWidget(
|
||||
'no ball is added on game over',
|
||||
setUp: (game, tester) async {
|
||||
whenListen(
|
||||
gameBloc,
|
||||
const Stream<GameState>.empty(),
|
||||
initialState: const GameState(
|
||||
score: 10,
|
||||
balls: 1,
|
||||
activatedBonusLetters: [],
|
||||
activatedDashNests: {},
|
||||
bonusHistory: [],
|
||||
),
|
||||
);
|
||||
await game.ready();
|
||||
|
||||
game.children.whereType<Ball>().first.controller.lost();
|
||||
await tester.pump();
|
||||
},
|
||||
verify: (game, tester) async {
|
||||
expect(
|
||||
game.children.whereType<Ball>().length,
|
||||
equals(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,139 @@
|
||||
// ignore_for_file: cascade_invocations
|
||||
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flame_test/flame_test.dart';
|
||||
import 'package:flutter/painting.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('BallController', () {
|
||||
late Ball ball;
|
||||
|
||||
setUp(() {
|
||||
ball = Ball(baseColor: const Color(0xFF00FFFF));
|
||||
});
|
||||
|
||||
flameTester.test(
|
||||
'lost removes ball',
|
||||
(game) async {
|
||||
await game.add(ball);
|
||||
final controller = BallController(ball);
|
||||
await ball.add(controller);
|
||||
await game.ready();
|
||||
|
||||
controller.lost();
|
||||
await game.ready();
|
||||
|
||||
expect(game.contains(ball), isFalse);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('LaunchedBallController', () {
|
||||
group('lost', () {
|
||||
late GameBloc gameBloc;
|
||||
late Ball ball;
|
||||
|
||||
setUp(() {
|
||||
gameBloc = MockGameBloc();
|
||||
ball = Ball(baseColor: const Color(0xFF00FFFF));
|
||||
whenListen(
|
||||
gameBloc,
|
||||
const Stream<GameState>.empty(),
|
||||
initialState: const GameState.initial(),
|
||||
);
|
||||
});
|
||||
|
||||
final tester = flameBlocTester<PinballGame>(
|
||||
game: PinballGameTest.create,
|
||||
gameBloc: () => gameBloc,
|
||||
);
|
||||
|
||||
tester.testGameWidget(
|
||||
'removes ball',
|
||||
verify: (game, tester) async {
|
||||
await game.add(ball);
|
||||
final controller = LaunchedBallController(ball);
|
||||
await ball.add(controller);
|
||||
await game.ready();
|
||||
|
||||
controller.lost();
|
||||
await game.ready();
|
||||
|
||||
expect(game.contains(ball), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
tester.testGameWidget(
|
||||
'adds BallLost to GameBloc',
|
||||
verify: (game, tester) async {
|
||||
final controller = LaunchedBallController(ball);
|
||||
await ball.add(controller);
|
||||
await game.add(ball);
|
||||
await game.ready();
|
||||
|
||||
controller.lost();
|
||||
|
||||
verify(() => gameBloc.add(const BallLost())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
tester.testGameWidget(
|
||||
'adds a new ball if the game is not over',
|
||||
verify: (game, tester) async {
|
||||
final controller = LaunchedBallController(ball);
|
||||
await ball.add(controller);
|
||||
await game.add(ball);
|
||||
await game.ready();
|
||||
|
||||
final previousBalls = game.descendants().whereType<Ball>().length;
|
||||
controller.lost();
|
||||
await game.ready();
|
||||
final currentBalls = game.descendants().whereType<Ball>().length;
|
||||
|
||||
expect(previousBalls, equals(currentBalls));
|
||||
},
|
||||
);
|
||||
|
||||
tester.testGameWidget(
|
||||
'no ball is added on game over',
|
||||
verify: (game, tester) async {
|
||||
whenListen(
|
||||
gameBloc,
|
||||
const Stream<GameState>.empty(),
|
||||
initialState: const GameState(
|
||||
score: 10,
|
||||
balls: 1,
|
||||
activatedBonusLetters: [],
|
||||
activatedDashNests: {},
|
||||
bonusHistory: [],
|
||||
),
|
||||
);
|
||||
final controller = BallController(ball);
|
||||
await ball.add(controller);
|
||||
await game.add(ball);
|
||||
await game.ready();
|
||||
|
||||
final previousBalls = game.descendants().whereType<Ball>().toList();
|
||||
controller.lost();
|
||||
await game.ready();
|
||||
final currentBalls = game.descendants().whereType<Ball>().length;
|
||||
|
||||
expect(
|
||||
currentBalls,
|
||||
equals((previousBalls..remove(ball)).length),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import 'package:flame_forge2d/flame_forge2d.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
|
||||
class FakeContact extends Fake implements Contact {}
|
||||
|
||||
class FakeGameEvent extends Fake implements GameEvent {}
|
@ -0,0 +1,7 @@
|
||||
import 'package:flame_forge2d/flame_forge2d.dart';
|
||||
|
||||
class TestGame extends Forge2DGame {
|
||||
TestGame() {
|
||||
images.prefix = '';
|
||||
}
|
||||
}
|
Loading…
Reference in new issue