feat: implemented new `FlutterForestBonusBehavior` logic (#303)

* refactor: moved Signpost to own folder

* feat: defined SignpostCubit

* feat: implemented new FlutterForestBonus logic

* chore: changed assets

* test: updated Signpost test and goldens

* feat: updated zoom effect goldens

* feat: adjusted signpost_test

* refactor: defined isFullyProgressed method

* test: tested FlutterForestBonusBehavior

* refactor: uncommented GameHud

* docs: updated FlutterForestBonusBehavior

* test: used canvas

* docs: enhanced documentation

* refactor: swapped active and inactive assets

* test: included canvas
pull/307/head
Alejandro Santiago 3 years ago committed by GitHub
parent ac4cceab44
commit 30cb1f9daf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -3,7 +3,10 @@ import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest]
/// Bonus obtained at the [FlutterForest].
///
/// When all [DashNestBumper]s are hit at least once three times, the [Signpost]
/// progresses. When the [Signpost] fully progresses, the [GameBonus.dashNest]
/// is awarded, and the [DashNestBumper.main] releases a new [Ball].
class FlutterForestBonusBehavior extends Component
with ParentIsA<FlutterForest>, HasGameRef<PinballGame> {
@ -12,28 +15,36 @@ class FlutterForestBonusBehavior extends Component
super.onMount();
final bumpers = parent.children.whereType<DashNestBumper>();
final signpost = parent.firstChild<Signpost>()!;
final animatronic = parent.firstChild<DashAnimatronic>()!;
final canvas = gameRef.firstChild<ZCanvasComponent>()!;
for (final bumper in bumpers) {
// TODO(alestiago): Refactor subscription management once the following is
// merged:
// https://github.com/flame-engine/flame/pull/1538
bumper.bloc.stream.listen((state) {
final achievedBonus = bumpers.every(
final activatedAllBumpers = bumpers.every(
(bumper) => bumper.bloc.state == DashNestBumperState.active,
);
if (achievedBonus) {
gameRef
.read<GameBloc>()
.add(const BonusActivated(GameBonus.dashNest));
gameRef.firstChild<ZCanvasComponent>()!.add(
ControlledBall.bonus(characterTheme: gameRef.characterTheme)
..initialPosition = Vector2(17.2, -52.7),
);
parent.firstChild<DashAnimatronic>()?.playing = true;
if (activatedAllBumpers) {
signpost.bloc.onProgressed();
for (final bumper in bumpers) {
bumper.bloc.onReset();
}
if (signpost.bloc.isFullyProgressed()) {
gameRef
.read<GameBloc>()
.add(const BonusActivated(GameBonus.dashNest));
canvas.add(
ControlledBall.bonus(characterTheme: gameRef.characterTheme)
..initialPosition = Vector2(17.2, -52.7),
);
animatronic.playing = true;
signpost.bloc.onProgressed();
}
}
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 45 KiB

@ -28,7 +28,7 @@ export 'plunger.dart';
export 'rocket.dart';
export 'score_component.dart';
export 'shapes/shapes.dart';
export 'signpost.dart';
export 'signpost/signpost.dart';
export 'slingshot.dart';
export 'spaceship_rail.dart';
export 'spaceship_ramp.dart';

@ -1,101 +0,0 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
/// Represents the [Signpost]'s current [Sprite] state.
@visibleForTesting
enum SignpostSpriteState {
/// Signpost with no active dashes.
inactive,
/// Signpost with a single sign of active dashes.
active1,
/// Signpost with two signs of active dashes.
active2,
/// Signpost with all signs of active dashes.
active3,
}
extension on SignpostSpriteState {
String get path {
switch (this) {
case SignpostSpriteState.inactive:
return Assets.images.signpost.inactive.keyName;
case SignpostSpriteState.active1:
return Assets.images.signpost.active1.keyName;
case SignpostSpriteState.active2:
return Assets.images.signpost.active2.keyName;
case SignpostSpriteState.active3:
return Assets.images.signpost.active3.keyName;
}
}
SignpostSpriteState get next {
return SignpostSpriteState
.values[(index + 1) % SignpostSpriteState.values.length];
}
}
/// {@template signpost}
/// A sign, found in the Flutter Forest.
///
/// Lights up a new sign whenever all three [DashNestBumper]s are hit.
/// {@endtemplate}
class Signpost extends BodyComponent with InitialPosition {
/// {@macro signpost}
Signpost({
Iterable<Component>? children,
}) : super(
renderBody: false,
children: [
_SignpostSpriteComponent(),
...?children,
],
);
/// Forwards the sprite to the next [SignpostSpriteState].
///
/// If the current state is the last one it cycles back to the initial state.
void progress() => firstChild<_SignpostSpriteComponent>()!.progress();
@override
Body createBody() {
final shape = CircleShape()..radius = 0.25;
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef(
position: initialPosition,
);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
class _SignpostSpriteComponent extends SpriteGroupComponent<SignpostSpriteState>
with HasGameRef {
_SignpostSpriteComponent()
: super(
anchor: Anchor.bottomCenter,
position: Vector2(0.65, 0.45),
);
void progress() => current = current?.next;
@override
Future<void> onLoad() async {
await super.onLoad();
final sprites = <SignpostSpriteState, Sprite>{};
this.sprites = sprites;
for (final spriteState in SignpostSpriteState.values) {
sprites[spriteState] = Sprite(
gameRef.images.fromCache(spriteState.path),
);
}
current = SignpostSpriteState.inactive;
size = sprites[current]!.originalSize / 10;
}
}

@ -0,0 +1,18 @@
// ignore_for_file: public_member_api_docs
import 'package:bloc/bloc.dart';
part 'signpost_state.dart';
class SignpostCubit extends Cubit<SignpostState> {
SignpostCubit() : super(SignpostState.inactive);
void onProgressed() {
final index = SignpostState.values.indexOf(state);
emit(
SignpostState.values[(index + 1) % SignpostState.values.length],
);
}
bool isFullyProgressed() => state == SignpostState.active3;
}

@ -0,0 +1,17 @@
// ignore_for_file: public_member_api_docs
part of 'signpost_cubit.dart';
enum SignpostState {
/// Signpost with no active eggs.
inactive,
/// Signpost with a single sign of lit up eggs.
active1,
/// Signpost with two signs of lit up eggs.
active2,
/// Signpost with all signs of lit up eggs.
active3,
}

@ -0,0 +1,109 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/foundation.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
export 'cubit/signpost_cubit.dart';
/// {@template signpost}
/// A sign, found in the Flutter Forest.
///
/// Lights up a new sign whenever all three [DashNestBumper]s are hit.
/// {@endtemplate}
class Signpost extends BodyComponent with InitialPosition {
/// {@macro signpost}
Signpost({
Iterable<Component>? children,
}) : this._(
children: children,
bloc: SignpostCubit(),
);
Signpost._({
Iterable<Component>? children,
required this.bloc,
}) : super(
renderBody: false,
children: [
_SignpostSpriteComponent(
current: bloc.state,
),
...?children,
],
);
/// Creates a [Signpost] without any children.
///
/// This can be used for testing [Signpost]'s behaviors in isolation.
// TODO(alestiago): Refactor injecting bloc once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
@visibleForTesting
Signpost.test({
required this.bloc,
});
// TODO(alestiago): Consider refactoring once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
// ignore: public_member_api_docs
final SignpostCubit bloc;
@override
void onRemove() {
bloc.close();
super.onRemove();
}
@override
Body createBody() {
final shape = CircleShape()..radius = 0.25;
final bodyDef = BodyDef(
position: initialPosition,
);
return world.createBody(bodyDef)..createFixtureFromShape(shape);
}
}
class _SignpostSpriteComponent extends SpriteGroupComponent<SignpostState>
with HasGameRef, ParentIsA<Signpost> {
_SignpostSpriteComponent({
required SignpostState current,
}) : super(
anchor: Anchor.bottomCenter,
position: Vector2(0.65, 0.45),
current: current,
);
@override
Future<void> onLoad() async {
await super.onLoad();
parent.bloc.stream.listen((state) => current = state);
final sprites = <SignpostState, Sprite>{};
this.sprites = sprites;
for (final spriteState in SignpostState.values) {
sprites[spriteState] = Sprite(
gameRef.images.fromCache(spriteState.path),
);
}
current = SignpostState.inactive;
size = sprites[current]!.originalSize / 10;
}
}
extension on SignpostState {
String get path {
switch (this) {
case SignpostState.inactive:
return Assets.images.signpost.inactive.keyName;
case SignpostState.active1:
return Assets.images.signpost.active1.keyName;
case SignpostState.active2:
return Assets.images.signpost.active2.keyName;
case SignpostState.active3:
return Assets.images.signpost.active3.keyName;
}
}
}

@ -34,6 +34,6 @@ class SignpostGame extends BallGame {
@override
void onTap() {
super.onTap();
firstChild<Signpost>()!.progress();
firstChild<Signpost>()!.bloc.onProgressed();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

@ -0,0 +1,39 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
void main() {
group('SignpostCubit', () {
blocTest<SignpostCubit, SignpostState>(
'onProgressed progresses',
build: SignpostCubit.new,
act: (bloc) {
bloc
..onProgressed()
..onProgressed()
..onProgressed()
..onProgressed();
},
expect: () => [
SignpostState.active1,
SignpostState.active2,
SignpostState.active3,
SignpostState.inactive,
],
);
test('isFullyProgressed when on active3', () {
final bloc = SignpostCubit();
expect(bloc.isFullyProgressed(), isFalse);
bloc.onProgressed();
expect(bloc.isFullyProgressed(), isFalse);
bloc.onProgressed();
expect(bloc.isFullyProgressed(), isFalse);
bloc.onProgressed();
expect(bloc.isFullyProgressed(), isTrue);
});
});
}

@ -1,11 +1,15 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
import '../../../helpers/helpers.dart';
class _MockSignpostCubit extends Mock implements SignpostCubit {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -18,13 +22,13 @@ void main() {
final flameTester = FlameTester(() => TestGame(assets));
group('Signpost', () {
const goldenPath = '../golden/signpost/';
flameTester.test(
'loads correctly',
(game) async {
final signpost = Signpost();
await game.ready();
await game.ensureAdd(signpost);
expect(game.contains(signpost), isTrue);
},
);
@ -39,8 +43,8 @@ void main() {
await tester.pump();
expect(
signpost.firstChild<SpriteGroupComponent>()!.current,
SignpostSpriteState.inactive,
signpost.bloc.state,
equals(SignpostState.inactive),
);
game.camera.followVector2(Vector2.zero());
@ -48,7 +52,7 @@ void main() {
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/signpost/inactive.png'),
matchesGoldenFile('${goldenPath}inactive.png'),
);
},
);
@ -59,12 +63,12 @@ void main() {
await game.images.loadAll(assets);
final signpost = Signpost();
await game.ensureAdd(signpost);
signpost.progress();
signpost.bloc.onProgressed();
await tester.pump();
expect(
signpost.firstChild<SpriteGroupComponent>()!.current,
SignpostSpriteState.active1,
signpost.bloc.state,
equals(SignpostState.active1),
);
game.camera.followVector2(Vector2.zero());
@ -72,7 +76,7 @@ void main() {
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/signpost/active1.png'),
matchesGoldenFile('${goldenPath}active1.png'),
);
},
);
@ -83,14 +87,14 @@ void main() {
await game.images.loadAll(assets);
final signpost = Signpost();
await game.ensureAdd(signpost);
signpost
..progress()
..progress();
signpost.bloc
..onProgressed()
..onProgressed();
await tester.pump();
expect(
signpost.firstChild<SpriteGroupComponent>()!.current,
SignpostSpriteState.active2,
signpost.bloc.state,
equals(SignpostState.active2),
);
game.camera.followVector2(Vector2.zero());
@ -98,7 +102,7 @@ void main() {
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/signpost/active2.png'),
matchesGoldenFile('${goldenPath}active2.png'),
);
},
);
@ -109,15 +113,16 @@ void main() {
await game.images.loadAll(assets);
final signpost = Signpost();
await game.ensureAdd(signpost);
signpost
..progress()
..progress()
..progress();
signpost.bloc
..onProgressed()
..onProgressed()
..onProgressed();
await tester.pump();
expect(
signpost.firstChild<SpriteGroupComponent>()!.current,
SignpostSpriteState.active3,
signpost.bloc.state,
equals(SignpostState.active3),
);
game.camera.followVector2(Vector2.zero());
@ -125,33 +130,12 @@ void main() {
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/signpost/active3.png'),
matchesGoldenFile('${goldenPath}active3.png'),
);
},
);
});
flameTester.test(
'progress correctly cycles through all sprites',
(game) async {
final signpost = Signpost();
await game.ready();
await game.ensureAdd(signpost);
final spriteComponent = signpost.firstChild<SpriteGroupComponent>()!;
expect(spriteComponent.current, SignpostSpriteState.inactive);
signpost.progress();
expect(spriteComponent.current, SignpostSpriteState.active1);
signpost.progress();
expect(spriteComponent.current, SignpostSpriteState.active2);
signpost.progress();
expect(spriteComponent.current, SignpostSpriteState.active3);
signpost.progress();
expect(spriteComponent.current, SignpostSpriteState.inactive);
},
);
flameTester.test('adds new children', (game) async {
final component = Component();
final signpost = Signpost(
@ -160,5 +144,22 @@ void main() {
await game.ensureAdd(signpost);
expect(signpost.children, contains(component));
});
flameTester.test('closes bloc when removed', (game) async {
final bloc = _MockSignpostCubit();
whenListen(
bloc,
const Stream<SignpostCubit>.empty(),
initialState: SignpostState.inactive,
);
when(bloc.close).thenAnswer((_) async {});
final component = Signpost.test(bloc: bloc);
await game.ensureAdd(component);
game.remove(component);
await game.ready();
verify(bloc.close).called(1);
});
});
}

@ -16,6 +16,7 @@ import '../../../../helpers/helpers.dart';
void main() {
group('FlutterForestBonusBehavior', () {
late GameBloc gameBloc;
final assets = [Assets.images.dash.animatronic.keyName];
setUp(() {
gameBloc = MockGameBloc();
@ -31,9 +32,14 @@ void main() {
blocBuilder: () => gameBloc,
);
void _contactedBumper(DashNestBumper bumper) =>
bumper.bloc.onBallContacted();
flameBlocTester.testGameWidget(
'adds GameBonus.dashNest to the game when all bumpers are active',
'adds GameBonus.dashNest to the game '
'when bumpers are activated three times',
setUp: (game, tester) async {
await game.images.loadAll(assets);
final behavior = FlutterForestBonusBehavior();
final parent = FlutterForest.test();
final bumpers = [
@ -41,12 +47,18 @@ void main() {
DashNestBumper.test(bloc: DashNestBumperCubit()),
DashNestBumper.test(bloc: DashNestBumperCubit()),
];
final animatronic = DashAnimatronic();
final signpost = Signpost.test(bloc: SignpostCubit());
await game.ensureAdd(ZCanvasComponent(children: [parent]));
await parent.ensureAddAll([...bumpers, behavior]);
await parent.ensureAddAll([...bumpers, animatronic, signpost]);
await parent.ensureAdd(behavior);
for (final bumper in bumpers) {
bumper.bloc.onBallContacted();
}
expect(game.descendants().whereType<DashNestBumper>(), equals(bumpers));
bumpers.forEach(_contactedBumper);
await tester.pump();
bumpers.forEach(_contactedBumper);
await tester.pump();
bumpers.forEach(_contactedBumper);
await tester.pump();
verify(
@ -56,8 +68,10 @@ void main() {
);
flameBlocTester.testGameWidget(
'adds a new ball to the game when all bumpers are active',
'adds a new Ball to the game '
'when bumpers are activated three times',
setUp: (game, tester) async {
await game.images.loadAll(assets);
final behavior = FlutterForestBonusBehavior();
final parent = FlutterForest.test();
final bumpers = [
@ -65,18 +79,68 @@ void main() {
DashNestBumper.test(bloc: DashNestBumperCubit()),
DashNestBumper.test(bloc: DashNestBumperCubit()),
];
final animatronic = DashAnimatronic();
final signpost = Signpost.test(bloc: SignpostCubit());
await game.ensureAdd(ZCanvasComponent(children: [parent]));
await parent.ensureAddAll([...bumpers, animatronic, signpost]);
await parent.ensureAdd(behavior);
for (final bumper in bumpers) {
bumper.bloc.onBallContacted();
}
expect(game.descendants().whereType<DashNestBumper>(), equals(bumpers));
bumpers.forEach(_contactedBumper);
await tester.pump();
bumpers.forEach(_contactedBumper);
await tester.pump();
bumpers.forEach(_contactedBumper);
await tester.pump();
await game.ready();
expect(
game.descendants().whereType<Ball>().length,
equals(1),
);
},
);
flameBlocTester.testGameWidget(
'progress the signpost '
'when bumpers are activated',
setUp: (game, tester) async {
await game.images.loadAll(assets);
final behavior = FlutterForestBonusBehavior();
final parent = FlutterForest.test();
final bumpers = [
DashNestBumper.test(bloc: DashNestBumperCubit()),
DashNestBumper.test(bloc: DashNestBumperCubit()),
DashNestBumper.test(bloc: DashNestBumperCubit()),
];
final animatronic = DashAnimatronic();
final signpost = Signpost.test(bloc: SignpostCubit());
await game.ensureAdd(ZCanvasComponent(children: [parent]));
await parent.ensureAddAll([...bumpers, animatronic, signpost]);
await parent.ensureAdd(behavior);
expect(game.descendants().whereType<DashNestBumper>(), equals(bumpers));
bumpers.forEach(_contactedBumper);
await tester.pump();
expect(
signpost.bloc.state,
equals(SignpostState.active1),
);
// expect(
// game.descendants().whereType<Ball>().single,
// isNotNull,
// );
bumpers.forEach(_contactedBumper);
await tester.pump();
expect(
signpost.bloc.state,
equals(SignpostState.active2),
);
bumpers.forEach(_contactedBumper);
await tester.pump();
expect(
signpost.bloc.state,
equals(SignpostState.inactive),
);
},
);
});

@ -4,6 +4,7 @@ import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import '../../../helpers/helpers.dart';
@ -31,8 +32,8 @@ void main() {
'loads correctly',
(game) async {
final flutterForest = FlutterForest();
await game.ensureAdd(flutterForest);
expect(game.contains(flutterForest), isTrue);
await game.ensureAdd(ZCanvasComponent(children: [flutterForest]));
expect(game.descendants(), contains(flutterForest));
},
);
@ -41,10 +42,9 @@ void main() {
'a Signpost',
(game) async {
final flutterForest = FlutterForest();
await game.ensureAdd(flutterForest);
await game.ensureAdd(ZCanvasComponent(children: [flutterForest]));
expect(
flutterForest.descendants().whereType<Signpost>().length,
game.descendants().whereType<Signpost>().length,
equals(1),
);
},
@ -54,11 +54,10 @@ void main() {
'a DashAnimatronic',
(game) async {
final flutterForest = FlutterForest();
await game.ensureAdd(flutterForest);
await game.ensureAdd(ZCanvasComponent(children: [flutterForest]));
expect(
flutterForest.firstChild<DashAnimatronic>(),
isNotNull,
game.descendants().whereType<DashAnimatronic>().length,
equals(1),
);
},
);
@ -67,10 +66,9 @@ void main() {
'three DashNestBumper',
(game) async {
final flutterForest = FlutterForest();
await game.ensureAdd(flutterForest);
await game.ensureAdd(ZCanvasComponent(children: [flutterForest]));
expect(
flutterForest.descendants().whereType<DashNestBumper>().length,
game.descendants().whereType<DashNestBumper>().length,
equals(3),
);
},

Loading…
Cancel
Save