feat: implemented `AndroidSpaceshipBonusBehavior` (#298)
* refactor: simplify ball entering spaceship * feat: add bonus logic and scoring * chore: add animatronic to spaceship sandbox * chore: remove renderBody change * chore: remove leftover children change * chore: test typo * refactor: re-add children property * refactor: use firstChild * refactor: PR suggestions * Update packages/pinball_components/test/src/components/android_animatronic_test.dart Co-authored-by: Alejandro Santiago <dev@alestiago.com> Co-authored-by: Alejandro Santiago <dev@alestiago.com>pull/305/head
@ -0,0 +1,27 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
import 'package:pinball_flame/pinball_flame.dart';
|
||||
|
||||
/// Adds a [GameBonus.androidSpaceship] when [AndroidSpaceship] has a bonus.
|
||||
class AndroidSpaceshipBonusBehavior extends Component
|
||||
with HasGameRef<PinballGame>, ParentIsA<AndroidAcres> {
|
||||
@override
|
||||
void onMount() {
|
||||
super.onMount();
|
||||
final androidSpaceship = parent.firstChild<AndroidSpaceship>()!;
|
||||
|
||||
// TODO(alestiago): Refactor subscription management once the following is
|
||||
// merged:
|
||||
// https://github.com/flame-engine/flame/pull/1538
|
||||
androidSpaceship.bloc.stream.listen((state) {
|
||||
final listenWhen = state == AndroidSpaceshipState.withBonus;
|
||||
if (!listenWhen) return;
|
||||
|
||||
gameRef
|
||||
.read<GameBloc>()
|
||||
.add(const BonusActivated(GameBonus.androidSpaceship));
|
||||
androidSpaceship.bloc.onBonusAwarded();
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export 'android_spaceship_bonus_behavior.dart';
|
@ -0,0 +1,71 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame_forge2d/flame_forge2d.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
import 'package:pinball_flame/pinball_flame.dart';
|
||||
|
||||
/// {@template android_animatronic}
|
||||
/// Animated Android that sits on top of the [AndroidSpaceship].
|
||||
/// {@endtemplate}
|
||||
class AndroidAnimatronic extends BodyComponent
|
||||
with InitialPosition, Layered, ZIndex {
|
||||
/// {@macro android_animatronic}
|
||||
AndroidAnimatronic({Iterable<Component>? children})
|
||||
: super(
|
||||
children: [
|
||||
_AndroidAnimatronicSpriteAnimationComponent(),
|
||||
...?children,
|
||||
],
|
||||
renderBody: false,
|
||||
) {
|
||||
layer = Layer.spaceship;
|
||||
zIndex = ZIndexes.androidHead;
|
||||
}
|
||||
|
||||
@override
|
||||
Body createBody() {
|
||||
final shape = EllipseShape(
|
||||
center: Vector2.zero(),
|
||||
majorRadius: 3.1,
|
||||
minorRadius: 2,
|
||||
)..rotate(1.4);
|
||||
final bodyDef = BodyDef(position: initialPosition);
|
||||
|
||||
return world.createBody(bodyDef)..createFixtureFromShape(shape);
|
||||
}
|
||||
}
|
||||
|
||||
class _AndroidAnimatronicSpriteAnimationComponent
|
||||
extends SpriteAnimationComponent with HasGameRef {
|
||||
_AndroidAnimatronicSpriteAnimationComponent()
|
||||
: super(
|
||||
anchor: Anchor.center,
|
||||
position: Vector2(-0.24, -2.6),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
await super.onLoad();
|
||||
|
||||
final spriteSheet = gameRef.images.fromCache(
|
||||
Assets.images.android.spaceship.animatronic.keyName,
|
||||
);
|
||||
|
||||
const amountPerRow = 18;
|
||||
const amountPerColumn = 4;
|
||||
final textureSize = Vector2(
|
||||
spriteSheet.width / amountPerRow,
|
||||
spriteSheet.height / amountPerColumn,
|
||||
);
|
||||
size = textureSize / 10;
|
||||
|
||||
animation = SpriteAnimation.fromFrameData(
|
||||
spriteSheet,
|
||||
SpriteAnimationData.sequenced(
|
||||
amount: amountPerRow * amountPerColumn,
|
||||
amountPerRow: amountPerRow,
|
||||
stepTime: 1 / 24,
|
||||
textureSize: textureSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'package:flame_forge2d/flame_forge2d.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
import 'package:pinball_flame/pinball_flame.dart';
|
||||
|
||||
class AndroidSpaceshipEntranceBallContactBehavior
|
||||
extends ContactBehavior<AndroidSpaceshipEntrance> {
|
||||
@override
|
||||
void beginContact(Object other, Contact contact) {
|
||||
super.beginContact(other, contact);
|
||||
if (other is! Ball) return;
|
||||
|
||||
parent.parent.bloc.onBallEntered();
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export 'android_spaceship_entrance_ball_contact_behavior.dart.dart';
|
@ -0,0 +1,13 @@
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
|
||||
part 'android_spaceship_state.dart';
|
||||
|
||||
class AndroidSpaceshipCubit extends Cubit<AndroidSpaceshipState> {
|
||||
AndroidSpaceshipCubit() : super(AndroidSpaceshipState.withoutBonus);
|
||||
|
||||
void onBallEntered() => emit(AndroidSpaceshipState.withBonus);
|
||||
|
||||
void onBonusAwarded() => emit(AndroidSpaceshipState.withoutBonus);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
part of 'android_spaceship_cubit.dart';
|
||||
|
||||
enum AndroidSpaceshipState {
|
||||
withoutBonus,
|
||||
withBonus,
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
// ignore_for_file: cascade_invocations
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame_test/flame_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
|
||||
import '../../helpers/helpers.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final asset = Assets.images.android.spaceship.animatronic.keyName;
|
||||
final flameTester = FlameTester(() => TestGame([asset]));
|
||||
|
||||
group('AndroidAnimatronic', () {
|
||||
flameTester.testGameWidget(
|
||||
'renders correctly',
|
||||
setUp: (game, tester) async {
|
||||
await game.images.load(asset);
|
||||
await game.ensureAdd(AndroidAnimatronic());
|
||||
game.camera.followVector2(Vector2.zero());
|
||||
await tester.pump();
|
||||
},
|
||||
verify: (game, tester) async {
|
||||
final animationDuration = game
|
||||
.firstChild<AndroidAnimatronic>()!
|
||||
.firstChild<SpriteAnimationComponent>()!
|
||||
.animation!
|
||||
.totalDuration();
|
||||
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('golden/android_animatronic/start.png'),
|
||||
);
|
||||
|
||||
game.update(animationDuration * 0.5);
|
||||
await tester.pump();
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('golden/android_animatronic/middle.png'),
|
||||
);
|
||||
|
||||
game.update(animationDuration * 0.5);
|
||||
await tester.pump();
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('golden/android_animatronic/end.png'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
flameTester.test(
|
||||
'loads correctly',
|
||||
(game) async {
|
||||
final androidAnimatronic = AndroidAnimatronic();
|
||||
await game.ensureAdd(androidAnimatronic);
|
||||
expect(game.contains(androidAnimatronic), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
flameTester.test('adds new children', (game) async {
|
||||
final component = Component();
|
||||
final androidAnimatronic = AndroidAnimatronic(
|
||||
children: [component],
|
||||
);
|
||||
await game.ensureAdd(androidAnimatronic);
|
||||
expect(androidAnimatronic.children, contains(component));
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
// 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 'package:pinball_components/src/components/android_spaceship/behaviors/behaviors.dart';
|
||||
import 'package:pinball_flame/pinball_flame.dart';
|
||||
|
||||
import '../../../helpers/helpers.dart';
|
||||
|
||||
class _MockAndroidSpaceshipCubit extends Mock implements AndroidSpaceshipCubit {
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('AndroidSpaceship', () {
|
||||
final assets = [
|
||||
Assets.images.android.spaceship.saucer.keyName,
|
||||
Assets.images.android.spaceship.lightBeam.keyName,
|
||||
];
|
||||
final flameTester = FlameTester(() => TestGame(assets));
|
||||
|
||||
flameTester.test('loads correctly', (game) async {
|
||||
final component = AndroidSpaceship(position: Vector2.zero());
|
||||
await game.ensureAdd(component);
|
||||
expect(game.contains(component), isTrue);
|
||||
});
|
||||
|
||||
flameTester.testGameWidget(
|
||||
'renders correctly',
|
||||
setUp: (game, tester) async {
|
||||
await game.images.loadAll(assets);
|
||||
final canvas = ZCanvasComponent(
|
||||
children: [AndroidSpaceship(position: Vector2.zero())],
|
||||
);
|
||||
await game.ensureAdd(canvas);
|
||||
game.camera.followVector2(Vector2.zero());
|
||||
await game.ready();
|
||||
await tester.pump();
|
||||
},
|
||||
verify: (game, tester) async {
|
||||
const goldenFilePath = '../golden/android_spaceship/';
|
||||
final animationDuration = game
|
||||
.descendants()
|
||||
.whereType<SpriteAnimationComponent>()
|
||||
.single
|
||||
.animation!
|
||||
.totalDuration();
|
||||
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('${goldenFilePath}start.png'),
|
||||
);
|
||||
|
||||
game.update(animationDuration * 0.5);
|
||||
await tester.pump();
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('${goldenFilePath}middle.png'),
|
||||
);
|
||||
|
||||
game.update(animationDuration * 0.5);
|
||||
await tester.pump();
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('${goldenFilePath}end.png'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// TODO(alestiago): Consider refactoring once the following is merged:
|
||||
// https://github.com/flame-engine/flame/pull/1538
|
||||
// ignore: public_member_api_docs
|
||||
flameTester.test('closes bloc when removed', (game) async {
|
||||
final bloc = _MockAndroidSpaceshipCubit();
|
||||
whenListen(
|
||||
bloc,
|
||||
const Stream<AndroidSpaceshipState>.empty(),
|
||||
initialState: AndroidSpaceshipState.withoutBonus,
|
||||
);
|
||||
when(bloc.close).thenAnswer((_) async {});
|
||||
final androidSpaceship = AndroidSpaceship.test(bloc: bloc);
|
||||
|
||||
await game.ensureAdd(androidSpaceship);
|
||||
game.remove(androidSpaceship);
|
||||
await game.ready();
|
||||
|
||||
verify(bloc.close).called(1);
|
||||
});
|
||||
|
||||
flameTester.test(
|
||||
'AndroidSpaceshipEntrance has an '
|
||||
'AndroidSpaceshipEntranceBallContactBehavior', (game) async {
|
||||
final androidSpaceship = AndroidSpaceship(position: Vector2.zero());
|
||||
await game.ensureAdd(androidSpaceship);
|
||||
|
||||
final androidSpaceshipEntrance =
|
||||
androidSpaceship.firstChild<AndroidSpaceshipEntrance>();
|
||||
expect(
|
||||
androidSpaceshipEntrance!.children
|
||||
.whereType<AndroidSpaceshipEntranceBallContactBehavior>()
|
||||
.single,
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
// ignore_for_file: cascade_invocations
|
||||
|
||||
import 'package:bloc_test/bloc_test.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 'package:pinball_components/src/components/android_spaceship/behaviors/behaviors.dart';
|
||||
|
||||
import '../../../../helpers/helpers.dart';
|
||||
|
||||
class _MockAndroidSpaceshipCubit extends Mock implements AndroidSpaceshipCubit {
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final flameTester = FlameTester(TestGame.new);
|
||||
|
||||
group(
|
||||
'AndroidSpaceshipEntranceBallContactBehavior',
|
||||
() {
|
||||
test('can be instantiated', () {
|
||||
expect(
|
||||
AndroidSpaceshipEntranceBallContactBehavior(),
|
||||
isA<AndroidSpaceshipEntranceBallContactBehavior>(),
|
||||
);
|
||||
});
|
||||
|
||||
flameTester.test(
|
||||
'beginContact calls onBallEntered when entrance contacts with a ball',
|
||||
(game) async {
|
||||
final behavior = AndroidSpaceshipEntranceBallContactBehavior();
|
||||
final bloc = _MockAndroidSpaceshipCubit();
|
||||
whenListen(
|
||||
bloc,
|
||||
const Stream<AndroidSpaceshipState>.empty(),
|
||||
initialState: AndroidSpaceshipState.withoutBonus,
|
||||
);
|
||||
|
||||
final entrance = AndroidSpaceshipEntrance();
|
||||
final androidSpaceship = AndroidSpaceship.test(
|
||||
bloc: bloc,
|
||||
children: [entrance],
|
||||
);
|
||||
await entrance.add(behavior);
|
||||
await game.ensureAdd(androidSpaceship);
|
||||
|
||||
behavior.beginContact(MockBall(), MockContact());
|
||||
|
||||
verify(androidSpaceship.bloc.onBallEntered).called(1);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
|
||||
void main() {
|
||||
group(
|
||||
'AndroidSpaceshipCubit',
|
||||
() {
|
||||
blocTest<AndroidSpaceshipCubit, AndroidSpaceshipState>(
|
||||
'onBallEntered emits withBonus',
|
||||
build: AndroidSpaceshipCubit.new,
|
||||
act: (bloc) => bloc.onBallEntered(),
|
||||
expect: () => [AndroidSpaceshipState.withBonus],
|
||||
);
|
||||
|
||||
blocTest<AndroidSpaceshipCubit, AndroidSpaceshipState>(
|
||||
'onBonusAwarded emits withoutBonus',
|
||||
build: AndroidSpaceshipCubit.new,
|
||||
act: (bloc) => bloc.onBonusAwarded(),
|
||||
expect: () => [AndroidSpaceshipState.withoutBonus],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
// ignore_for_file: cascade_invocations
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame_test/flame_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
import 'package:pinball_flame/pinball_flame.dart';
|
||||
|
||||
import '../../helpers/helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('AndroidSpaceship', () {
|
||||
final assets = [
|
||||
Assets.images.android.spaceship.saucer.keyName,
|
||||
Assets.images.android.spaceship.animatronic.keyName,
|
||||
Assets.images.android.spaceship.lightBeam.keyName,
|
||||
];
|
||||
final flameTester = FlameTester(() => TestGame(assets));
|
||||
|
||||
flameTester.test('loads correctly', (game) async {
|
||||
final component = AndroidSpaceship(position: Vector2.zero());
|
||||
await game.ensureAdd(component);
|
||||
expect(game.contains(component), isTrue);
|
||||
});
|
||||
|
||||
flameTester.testGameWidget(
|
||||
'renders correctly',
|
||||
setUp: (game, tester) async {
|
||||
await game.images.loadAll(assets);
|
||||
final canvas = ZCanvasComponent(
|
||||
children: [AndroidSpaceship(position: Vector2.zero())],
|
||||
);
|
||||
await game.ensureAdd(canvas);
|
||||
game.camera.followVector2(Vector2.zero());
|
||||
await game.ready();
|
||||
await tester.pump();
|
||||
},
|
||||
verify: (game, tester) async {
|
||||
final animationDuration = game
|
||||
.descendants()
|
||||
.whereType<SpriteAnimationComponent>()
|
||||
.last
|
||||
.animation!
|
||||
.totalDuration();
|
||||
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('golden/android_spaceship/start.png'),
|
||||
);
|
||||
|
||||
game.update(animationDuration * 0.5);
|
||||
await tester.pump();
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('golden/android_spaceship/middle.png'),
|
||||
);
|
||||
|
||||
game.update(animationDuration * 0.5);
|
||||
await tester.pump();
|
||||
await expectLater(
|
||||
find.byGame<TestGame>(),
|
||||
matchesGoldenFile('golden/android_spaceship/end.png'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 115 KiB |
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 114 KiB |
@ -0,0 +1,79 @@
|
||||
// ignore_for_file: cascade_invocations
|
||||
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flame/extensions.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/android_acres/behaviors/behaviors.dart';
|
||||
import 'package:pinball/game/game.dart';
|
||||
import 'package:pinball_components/pinball_components.dart';
|
||||
|
||||
import '../../../../helpers/helpers.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final assets = [
|
||||
Assets.images.android.spaceship.saucer.keyName,
|
||||
Assets.images.android.spaceship.animatronic.keyName,
|
||||
Assets.images.android.spaceship.lightBeam.keyName,
|
||||
Assets.images.android.ramp.boardOpening.keyName,
|
||||
Assets.images.android.ramp.railingForeground.keyName,
|
||||
Assets.images.android.ramp.railingBackground.keyName,
|
||||
Assets.images.android.ramp.main.keyName,
|
||||
Assets.images.android.ramp.arrow.inactive.keyName,
|
||||
Assets.images.android.ramp.arrow.active1.keyName,
|
||||
Assets.images.android.ramp.arrow.active2.keyName,
|
||||
Assets.images.android.ramp.arrow.active3.keyName,
|
||||
Assets.images.android.ramp.arrow.active4.keyName,
|
||||
Assets.images.android.ramp.arrow.active5.keyName,
|
||||
Assets.images.android.rail.main.keyName,
|
||||
Assets.images.android.rail.exit.keyName,
|
||||
Assets.images.android.bumper.a.lit.keyName,
|
||||
Assets.images.android.bumper.a.dimmed.keyName,
|
||||
Assets.images.android.bumper.b.lit.keyName,
|
||||
Assets.images.android.bumper.b.dimmed.keyName,
|
||||
Assets.images.android.bumper.cow.lit.keyName,
|
||||
Assets.images.android.bumper.cow.dimmed.keyName,
|
||||
];
|
||||
|
||||
group('AndroidSpaceshipBonusBehavior', () {
|
||||
late GameBloc gameBloc;
|
||||
|
||||
setUp(() {
|
||||
gameBloc = MockGameBloc();
|
||||
whenListen(
|
||||
gameBloc,
|
||||
const Stream<GameState>.empty(),
|
||||
initialState: const GameState.initial(),
|
||||
);
|
||||
});
|
||||
|
||||
final flameBlocTester = FlameBlocTester<PinballGame, GameBloc>(
|
||||
gameBuilder: EmptyPinballTestGame.new,
|
||||
blocBuilder: () => gameBloc,
|
||||
assets: assets,
|
||||
);
|
||||
|
||||
flameBlocTester.testGameWidget(
|
||||
'adds GameBonus.androidSpaceship to the game '
|
||||
'when android spacehship has a bonus',
|
||||
setUp: (game, tester) async {
|
||||
final behavior = AndroidSpaceshipBonusBehavior();
|
||||
final parent = AndroidAcres.test();
|
||||
final androidSpaceship = AndroidSpaceship(position: Vector2.zero());
|
||||
|
||||
await parent.add(androidSpaceship);
|
||||
await game.ensureAdd(parent);
|
||||
await parent.ensureAdd(behavior);
|
||||
|
||||
androidSpaceship.bloc.onBallEntered();
|
||||
await tester.pump();
|
||||
|
||||
verify(
|
||||
() => gameBloc.add(const BonusActivated(GameBonus.androidSpaceship)),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|