feat: implemented `Flapper` (#312)

* feat: add flapper

* chore: add assets to pinball game test

* fix: add mocks in file

* test: check animation onComplete

* fix: image cache in test

* Update packages/pinball_components/lib/src/components/flapper/flapper.dart

* style: commas and userData removal

* refactor: make launcher tests more robust

* refactor: removed children parameter

Co-authored-by: alestiago <dev@alestiago.com>
pull/328/head
Allison Ryan 2 years ago committed by GitHub
parent 82588602eb
commit 182e8f56cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -12,6 +12,7 @@ class Launcher extends Component {
: super(
children: [
LaunchRamp(),
Flapper(),
ControlledPlunger(compressionDistance: 9.2)
..initialPosition = Vector2(41.2, 43.7),
RocketSpriteComponent()..position = Vector2(43, 62.3),

@ -130,6 +130,9 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.score.twentyThousand.keyName),
images.load(components.Assets.images.score.twoHundredThousand.keyName),
images.load(components.Assets.images.score.oneMillion.keyName),
images.load(components.Assets.images.flapper.backSupport.keyName),
images.load(components.Assets.images.flapper.frontSupport.keyName),
images.load(components.Assets.images.flapper.flap.keyName),
images.load(dashTheme.leaderboardIcon.keyName),
images.load(sparkyTheme.leaderboardIcon.keyName),
images.load(androidTheme.leaderboardIcon.keyName),

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -22,6 +22,7 @@ class $AssetsImagesGen {
$AssetsImagesBoundaryGen get boundary => const $AssetsImagesBoundaryGen();
$AssetsImagesDashGen get dash => const $AssetsImagesDashGen();
$AssetsImagesDinoGen get dino => const $AssetsImagesDinoGen();
$AssetsImagesFlapperGen get flapper => const $AssetsImagesFlapperGen();
$AssetsImagesFlipperGen get flipper => const $AssetsImagesFlipperGen();
$AssetsImagesGoogleWordGen get googleWord =>
const $AssetsImagesGoogleWordGen();
@ -133,6 +134,22 @@ class $AssetsImagesDinoGen {
const AssetGenImage('assets/images/dino/top-wall.png');
}
class $AssetsImagesFlapperGen {
const $AssetsImagesFlapperGen();
/// File path: assets/images/flapper/back-support.png
AssetGenImage get backSupport =>
const AssetGenImage('assets/images/flapper/back-support.png');
/// File path: assets/images/flapper/flap.png
AssetGenImage get flap =>
const AssetGenImage('assets/images/flapper/flap.png');
/// File path: assets/images/flapper/front-support.png
AssetGenImage get frontSupport =>
const AssetGenImage('assets/images/flapper/front-support.png');
}
class $AssetsImagesFlipperGen {
const $AssetsImagesFlipperGen();

@ -14,6 +14,7 @@ export 'dash_animatronic.dart';
export 'dash_nest_bumper/dash_nest_bumper.dart';
export 'dino_walls.dart';
export 'fire_effect.dart';
export 'flapper/flapper.dart';
export 'flipper.dart';
export 'google_letter/google_letter.dart';
export 'initial_position.dart';

@ -0,0 +1,15 @@
// ignore_for_file: public_member_api_docs
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';
class FlapperSpinningBehavior extends ContactBehavior<FlapperEntrance> {
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! Ball) return;
parent.parent?.firstChild<SpriteAnimationComponent>()?.playing = true;
}
}

@ -0,0 +1,215 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_components/src/components/flapper/behaviors/behaviors.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template flapper}
/// Flap to let a [Ball] out of the [LaunchRamp] and to prevent [Ball]s from
/// going back in.
/// {@endtemplate}
class Flapper extends Component {
/// {@macro flapper}
Flapper()
: super(
children: [
FlapperEntrance(
children: [
FlapperSpinningBehavior(),
],
)..initialPosition = Vector2(4, -69.3),
_FlapperStructure(),
_FlapperExit()..initialPosition = Vector2(-0.6, -33.8),
_BackSupportSpriteComponent(),
_FrontSupportSpriteComponent(),
FlapSpriteAnimationComponent(),
],
);
/// Creates a [Flapper] without any children.
///
/// This can be used for testing [Flapper]'s behaviors in isolation.
@visibleForTesting
Flapper.test();
}
/// {@template flapper_entrance}
/// Sensor used in [FlapperSpinningBehavior] to animate
/// [FlapSpriteAnimationComponent].
/// {@endtemplate}
class FlapperEntrance extends BodyComponent with InitialPosition, Layered {
/// {@macro flapper_entrance}
FlapperEntrance({
Iterable<Component>? children,
}) : super(
children: children,
renderBody: false,
) {
layer = Layer.launcher;
}
@override
Body createBody() {
final shape = EdgeShape()
..set(
Vector2.zero(),
Vector2(0, 3.2),
);
final fixtureDef = FixtureDef(
shape,
isSensor: true,
);
final bodyDef = BodyDef(position: initialPosition);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
class _FlapperStructure extends BodyComponent with Layered {
_FlapperStructure() : super(renderBody: false) {
layer = Layer.board;
}
List<FixtureDef> _createFixtureDefs() {
final leftEdgeShape = EdgeShape()
..set(
Vector2(1.9, -69.3),
Vector2(1.9, -66),
);
final bottomEdgeShape = EdgeShape()
..set(
leftEdgeShape.vertex2,
Vector2(3.9, -66),
);
return [
FixtureDef(leftEdgeShape),
FixtureDef(bottomEdgeShape),
];
}
@override
Body createBody() {
final body = world.createBody(BodyDef());
_createFixtureDefs().forEach(body.createFixture);
return body;
}
}
class _FlapperExit extends LayerSensor {
_FlapperExit()
: super(
insideLayer: Layer.launcher,
outsideLayer: Layer.board,
orientation: LayerEntranceOrientation.down,
insideZIndex: ZIndexes.ballOnLaunchRamp,
outsideZIndex: ZIndexes.ballOnBoard,
) {
layer = Layer.launcher;
}
@override
Shape get shape => PolygonShape()
..setAsBox(
1.7,
0.1,
initialPosition,
1.5708,
);
}
/// {@template flap_sprite_animation_component}
/// Flap suspended between supports that animates to let the [Ball] exit the
/// [LaunchRamp].
/// {@endtemplate}
@visibleForTesting
class FlapSpriteAnimationComponent extends SpriteAnimationComponent
with HasGameRef, ZIndex {
/// {@macro flap_sprite_animation_component}
FlapSpriteAnimationComponent()
: super(
anchor: Anchor.center,
position: Vector2(2.8, -70.7),
playing: false,
) {
zIndex = ZIndexes.flapper;
}
@override
Future<void> onLoad() async {
await super.onLoad();
final spriteSheet = gameRef.images.fromCache(
Assets.images.flapper.flap.keyName,
);
const amountPerRow = 14;
const amountPerColumn = 1;
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,
loop: false,
),
)..onComplete = () {
animation?.reset();
playing = false;
};
}
}
class _BackSupportSpriteComponent extends SpriteComponent
with HasGameRef, ZIndex {
_BackSupportSpriteComponent()
: super(
anchor: Anchor.center,
position: Vector2(2.95, -70.6),
) {
zIndex = ZIndexes.flapperBack;
}
@override
Future<void> onLoad() async {
await super.onLoad();
final sprite = Sprite(
gameRef.images.fromCache(
Assets.images.flapper.backSupport.keyName,
),
);
this.sprite = sprite;
size = sprite.originalSize / 10;
}
}
class _FrontSupportSpriteComponent extends SpriteComponent
with HasGameRef, ZIndex {
_FrontSupportSpriteComponent()
: super(
anchor: Anchor.center,
position: Vector2(2.9, -67.6),
) {
zIndex = ZIndexes.flapperFront;
}
@override
Future<void> onLoad() async {
await super.onLoad();
final sprite = Sprite(
gameRef.images.fromCache(
Assets.images.flapper.frontSupport.keyName,
),
);
this.sprite = sprite;
size = sprite.originalSize / 10;
}
}

@ -1,7 +1,5 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball_components/pinball_components.dart';
@ -17,8 +15,6 @@ class LaunchRamp extends Component {
children: [
_LaunchRampBase(),
_LaunchRampForegroundRailing(),
_LaunchRampExit()..initialPosition = Vector2(0.6, -34),
_LaunchRampCloseWall()..initialPosition = Vector2(4, -69.5),
],
);
}
@ -109,8 +105,10 @@ class _LaunchRampBaseSpriteComponent extends SpriteComponent with HasGameRef {
Future<void> onLoad() async {
await super.onLoad();
final sprite = await gameRef.loadSprite(
Assets.images.launchRamp.ramp.keyName,
final sprite = Sprite(
gameRef.images.fromCache(
Assets.images.launchRamp.ramp.keyName,
),
);
this.sprite = sprite;
size = sprite.originalSize / 10;
@ -125,8 +123,10 @@ class _LaunchRampBackgroundRailingSpriteComponent extends SpriteComponent
Future<void> onLoad() async {
await super.onLoad();
final sprite = await gameRef.loadSprite(
Assets.images.launchRamp.backgroundRailing.keyName,
final sprite = Sprite(
gameRef.images.fromCache(
Assets.images.launchRamp.backgroundRailing.keyName,
),
);
this.sprite = sprite;
size = sprite.originalSize / 10;
@ -190,8 +190,10 @@ class _LaunchRampForegroundRailingSpriteComponent extends SpriteComponent
Future<void> onLoad() async {
await super.onLoad();
final sprite = await gameRef.loadSprite(
Assets.images.launchRamp.foregroundRailing.keyName,
final sprite = Sprite(
gameRef.images.fromCache(
Assets.images.launchRamp.foregroundRailing.keyName,
),
);
this.sprite = sprite;
size = sprite.originalSize / 10;
@ -199,51 +201,3 @@ class _LaunchRampForegroundRailingSpriteComponent extends SpriteComponent
position = Vector2(22.8, 0.5);
}
}
class _LaunchRampCloseWall extends BodyComponent with InitialPosition, Layered {
_LaunchRampCloseWall() : super(renderBody: false) {
layer = Layer.board;
}
@override
Body createBody() {
final shape = EdgeShape()..set(Vector2.zero(), Vector2(0, 3));
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()
..userData = this
..position = initialPosition;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
/// {@template launch_ramp_exit}
/// [LayerSensor] with [Layer.launcher] to filter [Ball]s exiting the
/// [LaunchRamp].
/// {@endtemplate}
class _LaunchRampExit extends LayerSensor {
/// {@macro launch_ramp_exit}
_LaunchRampExit()
: super(
insideLayer: Layer.launcher,
outsideLayer: Layer.board,
orientation: LayerEntranceOrientation.down,
insideZIndex: ZIndexes.ballOnLaunchRamp,
outsideZIndex: ZIndexes.ballOnBoard,
) {
layer = Layer.launcher;
}
static final Vector2 _size = Vector2(1.6, 0.1);
@override
Shape get shape => PolygonShape()
..setAsBox(
_size.x,
_size.y,
initialPosition,
math.pi / 2,
);
}

@ -45,6 +45,12 @@ abstract class ZIndexes {
static const launchRampForegroundRailing = _above + ballOnLaunchRamp;
static const flapperBack = _above + outerBoundary;
static const flapperFront = _above + flapper;
static const flapper = _above + ballOnLaunchRamp;
static const plunger = _above + launchRamp;
static const rocket = _below + bottomBoundary;

@ -88,6 +88,7 @@ flutter:
- assets/images/multiplier/x5/
- assets/images/multiplier/x6/
- assets/images/score/
- assets/images/flapper/
flutter_gen:
line_length: 80

@ -0,0 +1,53 @@
// 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_components/pinball_components.dart';
import 'package:pinball_components/src/components/flapper/behaviors/behaviors.dart';
import '../../../../helpers/helpers.dart';
class _MockBall extends Mock implements Ball {}
class _MockContact extends Mock implements Contact {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.flapper.flap.keyName,
Assets.images.flapper.backSupport.keyName,
Assets.images.flapper.frontSupport.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
group(
'FlapperSpinningBehavior',
() {
test('can be instantiated', () {
expect(
FlapperSpinningBehavior(),
isA<FlapperSpinningBehavior>(),
);
});
flameTester.test(
'beginContact plays the flapper animation',
(game) async {
final behavior = FlapperSpinningBehavior();
final entrance = FlapperEntrance();
final flap = FlapSpriteAnimationComponent();
final flapper = Flapper.test();
await flapper.addAll([entrance, flap]);
await entrance.add(behavior);
await game.ensureAdd(flapper);
behavior.beginContact(_MockBall(), _MockContact());
expect(flap.playing, isTrue);
},
);
},
);
}

@ -0,0 +1,100 @@
// 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_components/pinball_components.dart';
import 'package:pinball_components/src/components/flapper/behaviors/behaviors.dart';
import 'package:pinball_flame/pinball_flame.dart';
import '../../../helpers/helpers.dart';
void main() {
group('Flapper', () {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.flapper.flap.keyName,
Assets.images.flapper.backSupport.keyName,
Assets.images.flapper.frontSupport.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
flameTester.test('loads correctly', (game) async {
final component = Flapper();
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: [Flapper()]);
await game.ensureAdd(canvas);
game.camera
..followVector2(Vector2(3, -70))
..zoom = 25;
await tester.pump();
},
verify: (game, tester) async {
const goldenFilePath = '../golden/flapper/';
final flapSpriteAnimationComponent = game
.descendants()
.whereType<FlapSpriteAnimationComponent>()
.first
..playing = true;
final animationDuration =
flapSpriteAnimationComponent.animation!.totalDuration();
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('${goldenFilePath}start.png'),
);
game.update(animationDuration * 0.25);
await tester.pump();
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('${goldenFilePath}middle.png'),
);
game.update(animationDuration * 0.75);
await tester.pump();
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('${goldenFilePath}end.png'),
);
},
);
flameTester.test('adds a FlapperSpiningBehavior to FlapperEntrance',
(game) async {
final flapper = Flapper();
await game.ensureAdd(flapper);
final flapperEntrance = flapper.firstChild<FlapperEntrance>()!;
expect(
flapperEntrance.firstChild<FlapperSpinningBehavior>(),
isNotNull,
);
});
flameTester.test(
'flap stops animating after animation completes',
(game) async {
final flapper = Flapper();
await game.ensureAdd(flapper);
final flapSpriteAnimationComponent =
flapper.firstChild<FlapSpriteAnimationComponent>()!;
flapSpriteAnimationComponent.playing = true;
game.update(
flapSpriteAnimationComponent.animation!.totalDuration() + 0.1,
);
expect(flapSpriteAnimationComponent.playing, isFalse);
},
);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@ -9,7 +9,13 @@ import '../../helpers/helpers.dart';
void main() {
group('LaunchRamp', () {
final flameTester = FlameTester(TestGame.new);
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.launchRamp.ramp.keyName,
Assets.images.launchRamp.backgroundRailing.keyName,
Assets.images.launchRamp.foregroundRailing.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
flameTester.test('loads correctly', (game) async {
final component = LaunchRamp();
@ -20,9 +26,12 @@ void main() {
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {
await game.images.loadAll(assets);
await game.ensureAdd(LaunchRamp());
game.camera.followVector2(Vector2.zero());
game.camera.zoom = 4.1;
await game.ready();
await tester.pump();
},
verify: (game, tester) async {
await expectLater(

@ -0,0 +1,85 @@
// ignore_for_file: cascade_invocations
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 '../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.launchRamp.ramp.keyName,
Assets.images.launchRamp.backgroundRailing.keyName,
Assets.images.launchRamp.foregroundRailing.keyName,
Assets.images.flapper.backSupport.keyName,
Assets.images.flapper.frontSupport.keyName,
Assets.images.flapper.flap.keyName,
Assets.images.plunger.plunger.keyName,
Assets.images.plunger.rocket.keyName,
];
final flameTester = FlameTester(
() => EmptyPinballTestGame(assets: assets),
);
group('Launcher', () {
flameTester.test(
'loads correctly',
(game) async {
final launcher = Launcher();
await game.ensureAdd(launcher);
expect(game.contains(launcher), isTrue);
},
);
group('loads', () {
flameTester.test(
'a LaunchRamp',
(game) async {
final launcher = Launcher();
await game.ensureAdd(launcher);
final descendantsQuery =
launcher.descendants().whereType<LaunchRamp>();
expect(descendantsQuery.length, equals(1));
},
);
flameTester.test(
'a Flapper',
(game) async {
final launcher = Launcher();
await game.ensureAdd(launcher);
final descendantsQuery = launcher.descendants().whereType<Flapper>();
expect(descendantsQuery.length, equals(1));
},
);
flameTester.test(
'a Plunger',
(game) async {
final launcher = Launcher();
await game.ensureAdd(launcher);
final descendantsQuery = launcher.descendants().whereType<Plunger>();
expect(descendantsQuery.length, equals(1));
},
);
flameTester.test(
'a RocketSpriteComponent',
(game) async {
final launcher = Launcher();
await game.ensureAdd(launcher);
final descendantsQuery =
launcher.descendants().whereType<RocketSpriteComponent>();
expect(descendantsQuery.length, equals(1));
},
);
});
});
}

@ -124,6 +124,9 @@ void main() {
Assets.images.sparky.bumper.b.dimmed.keyName,
Assets.images.sparky.bumper.c.lit.keyName,
Assets.images.sparky.bumper.c.dimmed.keyName,
Assets.images.flapper.flap.keyName,
Assets.images.flapper.backSupport.keyName,
Assets.images.flapper.frontSupport.keyName,
];
late GameBloc gameBloc;

Loading…
Cancel
Save