Merge branch 'main' into feat/scaling-score-component

pull/391/head
Alejandro Santiago 3 years ago committed by GitHub
commit 6b6fb74d3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,5 +1,6 @@
export 'ball_spawning_behavior.dart'; export 'ball_spawning_behavior.dart';
export 'ball_theming_behavior.dart'; export 'ball_theming_behavior.dart';
export 'bonus_ball_spawning_behavior.dart';
export 'bonus_noise_behavior.dart'; export 'bonus_noise_behavior.dart';
export 'bumper_noise_behavior.dart'; export 'bumper_noise_behavior.dart';
export 'camera_focusing_behavior.dart'; export 'camera_focusing_behavior.dart';

@ -0,0 +1,30 @@
import 'package:flame/components.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template bonus_ball_spawning_behavior}
/// After a duration, spawns a bonus ball from the [DinoWalls] and boosts it
/// into the middle of the board.
/// {@endtemplate}
class BonusBallSpawningBehavior extends TimerComponent with HasGameRef {
/// {@macro bonus_ball_spawning_behavior}
BonusBallSpawningBehavior()
: super(
period: 5,
removeOnFinish: true,
);
@override
void onTick() {
final characterTheme = readBloc<CharacterThemeCubit, CharacterThemeState>()
.state
.characterTheme;
gameRef.descendants().whereType<ZCanvasComponent>().single.add(
Ball(assetPath: characterTheme.ball.keyName)
..add(BallImpulsingBehavior(impulse: Vector2(-40, 0)))
..initialPosition = Vector2(29.2, -24.5)
..zIndex = ZIndexes.ballOnBoard,
);
}
}

@ -25,10 +25,13 @@ class BonusNoiseBehavior extends Component {
audioPlayer.play(PinballAudio.sparky); audioPlayer.play(PinballAudio.sparky);
break; break;
case GameBonus.dinoChomp: case GameBonus.dinoChomp:
audioPlayer.play(PinballAudio.dino);
break; break;
case GameBonus.androidSpaceship: case GameBonus.androidSpaceship:
audioPlayer.play(PinballAudio.android);
break; break;
case GameBonus.dashNest: case GameBonus.dashNest:
audioPlayer.play(PinballAudio.dash);
break; break;
} }
}, },

@ -5,27 +5,37 @@ import 'package:flutter/material.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart';
import 'package:pinball/game/components/backbox/displays/displays.dart'; import 'package:pinball/game/components/backbox/displays/displays.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart' hide Assets;
import 'package:platform_helper/platform_helper.dart';
/// {@template backbox} /// {@template backbox}
/// The [Backbox] of the pinball machine. /// The [Backbox] of the pinball machine.
/// {@endtemplate} /// {@endtemplate}
class Backbox extends PositionComponent with ZIndex { class Backbox extends PositionComponent with ZIndex, HasGameRef {
/// {@macro backbox} /// {@macro backbox}
Backbox({ Backbox({
required LeaderboardRepository leaderboardRepository, required LeaderboardRepository leaderboardRepository,
}) : _bloc = BackboxBloc(leaderboardRepository: leaderboardRepository); required List<LeaderboardEntryData>? entries,
}) : _bloc = BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: entries,
),
_platformHelper = PlatformHelper();
/// {@macro backbox} /// {@macro backbox}
@visibleForTesting @visibleForTesting
Backbox.test({ Backbox.test({
required BackboxBloc bloc, required BackboxBloc bloc,
}) : _bloc = bloc; required PlatformHelper platformHelper,
}) : _bloc = bloc,
_platformHelper = platformHelper;
late final Component _display; late final Component _display;
final BackboxBloc _bloc; final BackboxBloc _bloc;
final PlatformHelper _platformHelper;
late StreamSubscription<BackboxState> _subscription; late StreamSubscription<BackboxState> _subscription;
@override @override
@ -34,8 +44,6 @@ class Backbox extends PositionComponent with ZIndex {
anchor = Anchor.bottomCenter; anchor = Anchor.bottomCenter;
zIndex = ZIndexes.backbox; zIndex = ZIndexes.backbox;
_bloc.add(LeaderboardRequested());
await add(_BackboxSpriteComponent()); await add(_BackboxSpriteComponent());
await add(_display = Component()); await add(_display = Component());
_build(_bloc.state); _build(_bloc.state);
@ -58,6 +66,9 @@ class Backbox extends PositionComponent with ZIndex {
} else if (state is LeaderboardSuccessState) { } else if (state is LeaderboardSuccessState) {
_display.add(LeaderboardDisplay(entries: state.entries)); _display.add(LeaderboardDisplay(entries: state.entries));
} else if (state is InitialsFormState) { } else if (state is InitialsFormState) {
if (_platformHelper.isMobile) {
gameRef.overlays.add(PinballGame.mobileControlsOverlay);
}
_display.add( _display.add(
InitialsInputDisplay( InitialsInputDisplay(
score: state.score, score: state.score,

@ -14,8 +14,13 @@ class BackboxBloc extends Bloc<BackboxEvent, BackboxState> {
/// {@macro backbox_bloc} /// {@macro backbox_bloc}
BackboxBloc({ BackboxBloc({
required LeaderboardRepository leaderboardRepository, required LeaderboardRepository leaderboardRepository,
required List<LeaderboardEntryData>? initialEntries,
}) : _leaderboardRepository = leaderboardRepository, }) : _leaderboardRepository = leaderboardRepository,
super(LoadingState()) { super(
initialEntries != null
? LeaderboardSuccessState(entries: initialEntries)
: LeaderboardFailureState(),
) {
on<PlayerInitialsRequested>(_onPlayerInitialsRequested); on<PlayerInitialsRequested>(_onPlayerInitialsRequested);
on<PlayerInitialsSubmitted>(_onPlayerInitialsSubmitted); on<PlayerInitialsSubmitted>(_onPlayerInitialsSubmitted);
on<LeaderboardRequested>(_onLeaderboardRequested); on<LeaderboardRequested>(_onLeaderboardRequested);

@ -1,6 +1,5 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
@ -40,7 +39,7 @@ class _BottomGroupSide extends Component {
final direction = _side.direction; final direction = _side.direction;
final centerXAdjustment = _side.isLeft ? -0.45 : -6.8; final centerXAdjustment = _side.isLeft ? -0.45 : -6.8;
final flipper = ControlledFlipper( final flipper = Flipper(
side: _side, side: _side,
)..initialPosition = Vector2((11.6 * direction) + centerXAdjustment, 43.6); )..initialPosition = Vector2((11.6 * direction) + centerXAdjustment, 43.6);
final baseboard = Baseboard(side: _side) final baseboard = Baseboard(side: _side)

@ -1,7 +1,6 @@
export 'android_acres/android_acres.dart'; export 'android_acres/android_acres.dart';
export 'backbox/backbox.dart'; export 'backbox/backbox.dart';
export 'bottom_group.dart'; export 'bottom_group.dart';
export 'controlled_flipper.dart';
export 'controlled_plunger.dart'; export 'controlled_plunger.dart';
export 'dino_desert/dino_desert.dart'; export 'dino_desert/dino_desert.dart';
export 'drain/drain.dart'; export 'drain/drain.dart';

@ -1,7 +1,7 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
@ -22,7 +22,6 @@ class FlutterForestBonusBehavior extends Component
final bumpers = parent.children.whereType<DashNestBumper>(); final bumpers = parent.children.whereType<DashNestBumper>();
final signpost = parent.firstChild<Signpost>()!; final signpost = parent.firstChild<Signpost>()!;
final animatronic = parent.firstChild<DashAnimatronic>()!; final animatronic = parent.firstChild<DashAnimatronic>()!;
final canvas = gameRef.descendants().whereType<ZCanvasComponent>().single;
for (final bumper in bumpers) { for (final bumper in bumpers) {
bumper.bloc.stream.listen((state) { bumper.bloc.stream.listen((state) {
@ -38,15 +37,7 @@ class FlutterForestBonusBehavior extends Component
if (signpost.bloc.isFullyProgressed()) { if (signpost.bloc.isFullyProgressed()) {
bloc.add(const BonusActivated(GameBonus.dashNest)); bloc.add(const BonusActivated(GameBonus.dashNest));
final characterTheme = add(BonusBallSpawningBehavior());
readBloc<CharacterThemeCubit, CharacterThemeState>()
.state
.characterTheme;
canvas.add(
Ball(assetPath: characterTheme.ball.keyName)
..initialPosition = Vector2(29.2, -24.5)
..zIndex = ZIndexes.ballOnBoard,
);
animatronic.playing = true; animatronic.playing = true;
signpost.bloc.onProgressed(); signpost.bloc.onProgressed();
} }

@ -3,6 +3,7 @@ import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
/// Listens to the [GameBloc] and updates the game accordingly. /// Listens to the [GameBloc] and updates the game accordingly.
@ -20,6 +21,11 @@ class GameBlocStatusListener extends Component
break; break;
case GameStatus.playing: case GameStatus.playing:
readProvider<PinballPlayer>().play(PinballAudio.backgroundMusic); readProvider<PinballPlayer>().play(PinballAudio.backgroundMusic);
gameRef
.descendants()
.whereType<Flipper>()
.forEach(_addFlipperKeyControls);
gameRef.overlays.remove(PinballGame.playButtonOverlay); gameRef.overlays.remove(PinballGame.playButtonOverlay);
break; break;
case GameStatus.gameOver: case GameStatus.gameOver:
@ -30,7 +36,20 @@ class GameBlocStatusListener extends Component
.state .state
.characterTheme, .characterTheme,
); );
gameRef
.descendants()
.whereType<Flipper>()
.forEach(_removeFlipperKeyControls);
break; break;
} }
} }
void _addFlipperKeyControls(Flipper flipper) =>
flipper.add(FlipperKeyControllingBehavior());
void _removeFlipperKeyControls(Flipper flipper) => flipper
.descendants()
.whereType<FlipperKeyControllingBehavior>()
.forEach(flipper.remove);
} }

@ -37,6 +37,9 @@ class PinballGame extends PinballForge2DGame
/// Identifier of the play button overlay /// Identifier of the play button overlay
static const playButtonOverlay = 'play_button'; static const playButtonOverlay = 'play_button';
/// Identifier of the mobile controls overlay
static const mobileControlsOverlay = 'mobile_controls';
@override @override
Color backgroundColor() => Colors.transparent; Color backgroundColor() => Colors.transparent;
@ -52,6 +55,18 @@ class PinballGame extends PinballForge2DGame
final GameBloc _gameBloc; final GameBloc _gameBloc;
List<LeaderboardEntryData>? _entries;
Future<void> preFetchLeaderboard() async {
try {
_entries = await leaderboardRepository.fetchTop10Leaderboard();
} catch (_) {
// An initial null leaderboard means that we couldn't fetch
// the entries for the [Backbox] and it will show the relevant display.
_entries = null;
}
}
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await add( await add(
@ -88,7 +103,10 @@ class PinballGame extends PinballForge2DGame
children: [ children: [
BoardBackgroundSpriteComponent(), BoardBackgroundSpriteComponent(),
Boundaries(), Boundaries(),
Backbox(leaderboardRepository: leaderboardRepository), Backbox(
leaderboardRepository: leaderboardRepository,
entries: _entries,
),
GoogleWord(position: Vector2(-4.45, 1.8)), GoogleWord(position: Vector2(-4.45, 1.8)),
Multipliers(), Multipliers(),
Multiballs(), Multiballs(),

@ -50,6 +50,7 @@ class PinballGamePage extends StatelessWidget {
); );
final loadables = [ final loadables = [
game.preFetchLeaderboard(),
...game.preLoadAssets(), ...game.preLoadAssets(),
...player.load(), ...player.load(),
...BonusAnimation.loadAssets(), ...BonusAnimation.loadAssets(),
@ -122,6 +123,14 @@ class PinballGameLoadedView extends StatelessWidget {
child: PlayButtonOverlay(), child: PlayButtonOverlay(),
); );
}, },
PinballGame.mobileControlsOverlay: (context, game) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: MobileControls(game: game),
);
},
}, },
), ),
), ),

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// {@template mobile_controls}
/// Widget with the controls used to enable the user initials input on mobile.
/// {@endtemplate}
class MobileControls extends StatelessWidget {
/// {@macro mobile_controls}
const MobileControls({
Key? key,
required this.game,
}) : super(key: key);
/// Game instance
final PinballGame game;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
MobileDpad(
onTapUp: () => game.triggerVirtualKeyUp(LogicalKeyboardKey.arrowUp),
onTapDown: () => game.triggerVirtualKeyUp(
LogicalKeyboardKey.arrowDown,
),
onTapLeft: () => game.triggerVirtualKeyUp(
LogicalKeyboardKey.arrowLeft,
),
onTapRight: () => game.triggerVirtualKeyUp(
LogicalKeyboardKey.arrowRight,
),
),
PinballButton(
text: l10n.enter,
onTap: () => game.triggerVirtualKeyUp(LogicalKeyboardKey.enter),
),
],
);
}
}

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// {@template mobile_dpad}
/// Widget rendering 4 directional input arrows.
/// {@endtemplate}
class MobileDpad extends StatelessWidget {
/// {@template mobile_dpad}
const MobileDpad({
Key? key,
required this.onTapUp,
required this.onTapDown,
required this.onTapLeft,
required this.onTapRight,
}) : super(key: key);
static const _size = 180.0;
/// Called when dpad up is pressed
final VoidCallback onTapUp;
/// Called when dpad down is pressed
final VoidCallback onTapDown;
/// Called when dpad left is pressed
final VoidCallback onTapLeft;
/// Called when dpad right is pressed
final VoidCallback onTapRight;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _size,
height: _size,
child: Column(
children: [
Row(
children: [
const Spacer(),
PinballDpadButton(
direction: PinballDpadDirection.up,
onTap: onTapUp,
),
const Spacer(),
],
),
Row(
children: [
PinballDpadButton(
direction: PinballDpadDirection.left,
onTap: onTapLeft,
),
const Spacer(),
PinballDpadButton(
direction: PinballDpadDirection.right,
onTap: onTapRight,
),
],
),
Row(
children: [
const Spacer(),
PinballDpadButton(
direction: PinballDpadDirection.down,
onTap: onTapDown,
),
const Spacer(),
],
),
],
),
);
}
}

@ -1,5 +1,7 @@
export 'bonus_animation.dart'; export 'bonus_animation.dart';
export 'game_hud.dart'; export 'game_hud.dart';
export 'mobile_controls.dart';
export 'mobile_dpad.dart';
export 'play_button_overlay.dart'; export 'play_button_overlay.dart';
export 'round_count_display.dart'; export 'round_count_display.dart';
export 'score_view.dart'; export 'score_view.dart';

@ -240,7 +240,7 @@ class _DesktopFlipperControls extends StatelessWidget {
children: [ children: [
Text( Text(
l10n.flipperControls, l10n.flipperControls,
style: Theme.of(context).textTheme.subtitle2, style: Theme.of(context).textTheme.headline4,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Column( Column(

@ -151,5 +151,9 @@
"ioPinball": "I/O Pinball", "ioPinball": "I/O Pinball",
"@ioPinball": { "@ioPinball": {
"description": "I/O Pinball - Name of the game" "description": "I/O Pinball - Name of the game"
},
"enter": "Enter",
"@enter": {
"description": "Text shown on the mobile controls enter button"
} }
} }

@ -14,8 +14,11 @@ class $AssetsMusicGen {
class $AssetsSfxGen { class $AssetsSfxGen {
const $AssetsSfxGen(); const $AssetsSfxGen();
String get android => 'assets/sfx/android.mp3';
String get bumperA => 'assets/sfx/bumper_a.mp3'; String get bumperA => 'assets/sfx/bumper_a.mp3';
String get bumperB => 'assets/sfx/bumper_b.mp3'; String get bumperB => 'assets/sfx/bumper_b.mp3';
String get dash => 'assets/sfx/dash.mp3';
String get dino => 'assets/sfx/dino.mp3';
String get gameOverVoiceOver => 'assets/sfx/game_over_voice_over.mp3'; String get gameOverVoiceOver => 'assets/sfx/game_over_voice_over.mp3';
String get google => 'assets/sfx/google.mp3'; String get google => 'assets/sfx/google.mp3';
String get ioPinballVoiceOver => 'assets/sfx/io_pinball_voice_over.mp3'; String get ioPinballVoiceOver => 'assets/sfx/io_pinball_voice_over.mp3';

@ -28,6 +28,15 @@ enum PinballAudio {
/// Sparky /// Sparky
sparky, sparky,
/// Android
android,
/// Dino
dino,
/// Dash
dash,
} }
/// Defines the contract of the creation of an [AudioPool]. /// Defines the contract of the creation of an [AudioPool].
@ -169,6 +178,21 @@ class PinballPlayer {
playSingleAudio: _playSingleAudio, playSingleAudio: _playSingleAudio,
path: Assets.sfx.sparky, path: Assets.sfx.sparky,
), ),
PinballAudio.dino: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
path: Assets.sfx.dino,
),
PinballAudio.dash: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
path: Assets.sfx.dash,
),
PinballAudio.android: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
path: Assets.sfx.android,
),
PinballAudio.launcher: _SimplePlayAudio( PinballAudio.launcher: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio, preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio, playSingleAudio: _playSingleAudio,

@ -145,6 +145,18 @@ void main() {
() => preCacheSingleAudio () => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/sparky.mp3'), .onCall('packages/pinball_audio/assets/sfx/sparky.mp3'),
).called(1); ).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/dino.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/android.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/dash.mp3'),
).called(1);
verify( verify(
() => preCacheSingleAudio.onCall( () => preCacheSingleAudio.onCall(
'packages/pinball_audio/assets/sfx/io_pinball_voice_over.mp3', 'packages/pinball_audio/assets/sfx/io_pinball_voice_over.mp3',
@ -239,6 +251,42 @@ void main() {
}); });
}); });
group('dino', () {
test('plays the correct file', () async {
await Future.wait(player.load());
player.play(PinballAudio.dino);
verify(
() => playSingleAudio
.onCall('packages/pinball_audio/${Assets.sfx.dino}'),
).called(1);
});
});
group('android', () {
test('plays the correct file', () async {
await Future.wait(player.load());
player.play(PinballAudio.android);
verify(
() => playSingleAudio
.onCall('packages/pinball_audio/${Assets.sfx.android}'),
).called(1);
});
});
group('dash', () {
test('plays the correct file', () async {
await Future.wait(player.load());
player.play(PinballAudio.dash);
verify(
() => playSingleAudio
.onCall('packages/pinball_audio/${Assets.sfx.dash}'),
).called(1);
});
});
group('launcher', () { group('launcher', () {
test('plays the correct file', () async { test('plays the correct file', () async {
await Future.wait(player.load()); await Future.wait(player.load());

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@ -0,0 +1,22 @@
import 'package:flame/components.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template ball_impulsing_behavior}
/// Impulses the [Ball] in a given direction.
/// {@endtemplate}
class BallImpulsingBehavior extends Component with ParentIsA<Ball> {
/// {@macro ball_impulsing_behavior}
BallImpulsingBehavior({
required Vector2 impulse,
}) : _impulse = impulse;
final Vector2 _impulse;
@override
Future<void> onLoad() async {
await super.onLoad();
parent.body.linearVelocity = _impulse;
shouldRemove = true;
}
}

@ -1,3 +1,4 @@
export 'ball_gravitating_behavior.dart'; export 'ball_gravitating_behavior.dart';
export 'ball_impulsing_behavior.dart';
export 'ball_scaling_behavior.dart'; export 'ball_scaling_behavior.dart';
export 'ball_turbo_charging_behavior.dart'; export 'ball_turbo_charging_behavior.dart';

@ -15,7 +15,7 @@ export 'dino_walls.dart';
export 'error_component.dart'; export 'error_component.dart';
export 'fire_effect.dart'; export 'fire_effect.dart';
export 'flapper/flapper.dart'; export 'flapper/flapper.dart';
export 'flipper.dart'; export 'flipper/flipper.dart';
export 'google_letter/google_letter.dart'; export 'google_letter/google_letter.dart';
export 'initial_position.dart'; export 'initial_position.dart';
export 'joint_anchor.dart'; export 'joint_anchor.dart';

@ -0,0 +1,2 @@
export 'flipper_jointing_behavior.dart';
export 'flipper_key_controlling_behavior.dart';

@ -0,0 +1,103 @@
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';
/// Joints the [Flipper] to allow pivoting around one end.
class FlipperJointingBehavior extends Component
with ParentIsA<Flipper>, HasGameRef {
late final RevoluteJoint _joint;
@override
Future<void> onLoad() async {
await super.onLoad();
final anchor = _FlipperAnchor(flipper: parent);
await add(anchor);
final jointDef = _FlipperAnchorRevoluteJointDef(
flipper: parent,
anchor: anchor,
);
_joint = _FlipperJoint(jointDef);
parent.world.createJoint(_joint);
}
@override
void onMount() {
gameRef.ready().whenComplete(
() => parent.body.joints.whereType<_FlipperJoint>().first.unlock(),
);
}
}
/// {@template flipper_anchor}
/// [JointAnchor] positioned at the end of a [Flipper].
///
/// The end of a [Flipper] depends on its [Flipper.side].
/// {@endtemplate}
class _FlipperAnchor extends JointAnchor {
/// {@macro flipper_anchor}
_FlipperAnchor({
required Flipper flipper,
}) {
initialPosition = Vector2(
(Flipper.size.x * flipper.side.direction) / 2 -
(1.65 * flipper.side.direction),
-0.15,
);
}
}
/// {@template flipper_anchor_revolute_joint_def}
/// Hinges one end of [Flipper] to a [_FlipperAnchor] to achieve a potivoting
/// motion.
/// {@endtemplate}
class _FlipperAnchorRevoluteJointDef extends RevoluteJointDef {
/// {@macro flipper_anchor_revolute_joint_def}
_FlipperAnchorRevoluteJointDef({
required Flipper flipper,
required _FlipperAnchor anchor,
}) : side = flipper.side {
enableLimit = true;
initialize(
flipper.body,
anchor.body,
flipper.body.position + anchor.body.position,
);
}
final BoardSide side;
}
/// {@template flipper_joint}
/// [RevoluteJoint] that controls the pivoting motion of a [Flipper].
/// {@endtemplate}
class _FlipperJoint extends RevoluteJoint {
/// {@macro flipper_joint}
_FlipperJoint(_FlipperAnchorRevoluteJointDef def)
: side = def.side,
super(def) {
lock();
}
/// Half the angle of the arc motion.
static const _halfSweepingAngle = 0.611;
final BoardSide side;
/// Locks the [Flipper] to its resting position.
///
/// The joint is locked when initialized in order to force the [Flipper]
/// at its resting position.
void lock() {
final angle = _halfSweepingAngle * side.direction;
setLimits(angle, angle);
}
/// Unlocks the [Flipper] from its resting position.
void unlock() {
const angle = _halfSweepingAngle;
setLimits(-angle, angle);
}
}

@ -1,49 +1,33 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
/// {@template controlled_flipper} /// Allows controlling the [Flipper]'s movement with keyboard input.
/// A [Flipper] with a [FlipperController] attached. class FlipperKeyControllingBehavior extends Component
/// {@endtemplate} with KeyboardHandler, ParentIsA<Flipper> {
class ControlledFlipper extends Flipper with Controls<FlipperController> {
/// {@macro controlled_flipper}
ControlledFlipper({
required BoardSide side,
}) : super(side: side) {
controller = FlipperController(this);
}
}
/// {@template flipper_controller}
/// A [ComponentController] that controls a [Flipper]s movement.
/// {@endtemplate}
class FlipperController extends ComponentController<Flipper>
with KeyboardHandler, FlameBlocReader<GameBloc, GameState> {
/// {@macro flipper_controller}
FlipperController(Flipper flipper)
: _keys = flipper.side.flipperKeys,
super(flipper);
/// The [LogicalKeyboardKey]s that will control the [Flipper]. /// The [LogicalKeyboardKey]s that will control the [Flipper].
/// ///
/// [onKeyEvent] method listens to when one of these keys is pressed. /// [onKeyEvent] method listens to when one of these keys is pressed.
final List<LogicalKeyboardKey> _keys; late final List<LogicalKeyboardKey> _keys;
@override
Future<void> onLoad() async {
await super.onLoad();
_keys = parent.side.flipperKeys;
}
@override @override
bool onKeyEvent( bool onKeyEvent(
RawKeyEvent event, RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed, Set<LogicalKeyboardKey> keysPressed,
) { ) {
if (!bloc.state.status.isPlaying) return true;
if (!_keys.contains(event.logicalKey)) return true; if (!_keys.contains(event.logicalKey)) return true;
if (event is RawKeyDownEvent) { if (event is RawKeyDownEvent) {
component.moveUp(); parent.moveUp();
} else if (event is RawKeyUpEvent) { } else if (event is RawKeyUpEvent) {
component.moveDown(); parent.moveDown();
} }
return false; return false;

@ -2,8 +2,11 @@ import 'dart:async';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/foundation.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
export 'behaviors/behaviors.dart';
/// {@template flipper} /// {@template flipper}
/// A bat, typically found in pairs at the bottom of the board. /// A bat, typically found in pairs at the bottom of the board.
/// ///
@ -15,9 +18,18 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
required this.side, required this.side,
}) : super( }) : super(
renderBody: false, renderBody: false,
children: [_FlipperSpriteComponent(side: side)], children: [
_FlipperSpriteComponent(side: side),
FlipperJointingBehavior(),
],
); );
/// Creates a [Flipper] without any children.
///
/// This can be used for testing [Flipper]'s behaviors in isolation.
@visibleForTesting
Flipper.test({required this.side});
/// The size of the [Flipper]. /// The size of the [Flipper].
static final size = Vector2(13.5, 4.3); static final size = Vector2(13.5, 4.3);
@ -44,19 +56,6 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
body.linearVelocity = Vector2(0, -_speed); body.linearVelocity = Vector2(0, -_speed);
} }
/// Anchors the [Flipper] to the [RevoluteJoint] that controls its arc motion.
Future<void> _anchorToJoint() async {
final anchor = _FlipperAnchor(flipper: this);
await add(anchor);
final jointDef = _FlipperAnchorRevoluteJointDef(
flipper: this,
anchor: anchor,
);
final joint = _FlipperJoint(jointDef);
world.createJoint(joint);
}
List<FixtureDef> _createFixtureDefs() { List<FixtureDef> _createFixtureDefs() {
final direction = side.direction; final direction = side.direction;
@ -73,7 +72,6 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
assetShadow, assetShadow,
0, 0,
); );
final bigCircleFixtureDef = FixtureDef(bigCircleShape);
final smallCircleShape = CircleShape()..radius = size.y * 0.23; final smallCircleShape = CircleShape()..radius = size.y * 0.23;
smallCircleShape.position.setValues( smallCircleShape.position.setValues(
@ -82,7 +80,6 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
assetShadow, assetShadow,
0, 0,
); );
final smallCircleFixtureDef = FixtureDef(smallCircleShape);
final trapeziumVertices = side.isLeft final trapeziumVertices = side.isLeft
? [ ? [
@ -98,26 +95,18 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
Vector2(smallCircleShape.position.x, -smallCircleShape.radius), Vector2(smallCircleShape.position.x, -smallCircleShape.radius),
]; ];
final trapezium = PolygonShape()..set(trapeziumVertices); final trapezium = PolygonShape()..set(trapeziumVertices);
final trapeziumFixtureDef = FixtureDef(
return [
FixtureDef(bigCircleShape),
FixtureDef(smallCircleShape),
FixtureDef(
trapezium, trapezium,
density: 50, density: 50,
friction: .1, friction: .1,
); ),
return [
bigCircleFixtureDef,
smallCircleFixtureDef,
trapeziumFixtureDef,
]; ];
} }
@override
Future<void> onLoad() async {
await super.onLoad();
await _anchorToJoint();
}
@override @override
Body createBody() { Body createBody() {
final bodyDef = BodyDef( final bodyDef = BodyDef(
@ -131,15 +120,6 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
return body; return body;
} }
@override
void onMount() {
super.onMount();
gameRef.ready().whenComplete(
() => body.joints.whereType<_FlipperJoint>().first.unlock(),
);
}
} }
class _FlipperSpriteComponent extends SpriteComponent with HasGameRef { class _FlipperSpriteComponent extends SpriteComponent with HasGameRef {
@ -163,73 +143,3 @@ class _FlipperSpriteComponent extends SpriteComponent with HasGameRef {
size = sprite.originalSize / 10; size = sprite.originalSize / 10;
} }
} }
/// {@template flipper_anchor}
/// [JointAnchor] positioned at the end of a [Flipper].
///
/// The end of a [Flipper] depends on its [Flipper.side].
/// {@endtemplate}
class _FlipperAnchor extends JointAnchor {
/// {@macro flipper_anchor}
_FlipperAnchor({
required Flipper flipper,
}) {
initialPosition = Vector2(
(Flipper.size.x * flipper.side.direction) / 2 -
(1.65 * flipper.side.direction),
-0.15,
);
}
}
/// {@template flipper_anchor_revolute_joint_def}
/// Hinges one end of [Flipper] to a [_FlipperAnchor] to achieve an arc motion.
/// {@endtemplate}
class _FlipperAnchorRevoluteJointDef extends RevoluteJointDef {
/// {@macro flipper_anchor_revolute_joint_def}
_FlipperAnchorRevoluteJointDef({
required Flipper flipper,
required _FlipperAnchor anchor,
}) : side = flipper.side {
enableLimit = true;
initialize(
flipper.body,
anchor.body,
flipper.body.position + anchor.body.position,
);
}
final BoardSide side;
}
/// {@template flipper_joint}
/// [RevoluteJoint] that controls the arc motion of a [Flipper].
/// {@endtemplate}
class _FlipperJoint extends RevoluteJoint {
/// {@macro flipper_joint}
_FlipperJoint(_FlipperAnchorRevoluteJointDef def)
: side = def.side,
super(def) {
lock();
}
/// Half the angle of the arc motion.
static const _halfSweepingAngle = 0.611;
final BoardSide side;
/// Locks the [Flipper] to its resting position.
///
/// The joint is locked when initialized in order to force the [Flipper]
/// at its resting position.
void lock() {
final angle = _halfSweepingAngle * side.direction;
setLimits(angle, angle);
}
/// Unlocks the [Flipper] from its resting position.
void unlock() {
const angle = _halfSweepingAngle;
setLimits(-angle, angle);
}
}

@ -42,7 +42,7 @@ class SpaceshipRamp extends Component {
_SpaceshipRampBackground(), _SpaceshipRampBackground(),
_SpaceshipRampBoardOpening()..initialPosition = Vector2(3.4, -39.5), _SpaceshipRampBoardOpening()..initialPosition = Vector2(3.4, -39.5),
_SpaceshipRampForegroundRailing(), _SpaceshipRampForegroundRailing(),
_SpaceshipRampBase()..initialPosition = Vector2(3.4, -42.5), SpaceshipRampBase()..initialPosition = Vector2(3.4, -42.5),
_SpaceshipRampBackgroundRailingSpriteComponent(), _SpaceshipRampBackgroundRailingSpriteComponent(),
SpaceshipRampArrowSpriteComponent( SpaceshipRampArrowSpriteComponent(
current: bloc.state.hits, current: bloc.state.hits,
@ -255,9 +255,14 @@ class _SpaceshipRampBoardOpening extends BodyComponent
_SpaceshipRampBoardOpeningSpriteComponent(), _SpaceshipRampBoardOpeningSpriteComponent(),
LayerContactBehavior(layer: Layer.spaceshipEntranceRamp) LayerContactBehavior(layer: Layer.spaceshipEntranceRamp)
..applyTo(['inside']), ..applyTo(['inside']),
LayerContactBehavior(layer: Layer.board)..applyTo(['outside']), LayerContactBehavior(
ZIndexContactBehavior(zIndex: ZIndexes.ballOnBoard) layer: Layer.board,
..applyTo(['outside']), onBegin: false,
)..applyTo(['outside']),
ZIndexContactBehavior(
zIndex: ZIndexes.ballOnBoard,
onBegin: false,
)..applyTo(['outside']),
ZIndexContactBehavior(zIndex: ZIndexes.ballOnSpaceshipRamp) ZIndexContactBehavior(zIndex: ZIndexes.ballOnSpaceshipRamp)
..applyTo(['middle', 'inside']), ..applyTo(['middle', 'inside']),
], ],
@ -426,9 +431,19 @@ class _SpaceshipRampForegroundRailingSpriteComponent extends SpriteComponent
} }
} }
class _SpaceshipRampBase extends BodyComponent with Layered, InitialPosition { @visibleForTesting
_SpaceshipRampBase() : super(renderBody: false) { class SpaceshipRampBase extends BodyComponent
layer = Layer.board; with InitialPosition, ContactCallbacks {
SpaceshipRampBase() : super(renderBody: false);
@override
void preSolve(Object other, Contact contact, Manifold oldManifold) {
super.preSolve(other, contact, oldManifold);
if (other is! Layered) return;
// Although, the Layer should already be taking care of the contact
// filtering, this is to ensure the ball doesn't collide with the ramp base
// when the filtering is calculated on different time steps.
contact.setEnabled(other.layer == Layer.board);
} }
@override @override
@ -441,7 +456,7 @@ class _SpaceshipRampBase extends BodyComponent with Layered, InitialPosition {
Vector2(4.1, 1.5), Vector2(4.1, 1.5),
], ],
); );
final bodyDef = BodyDef(position: initialPosition); final bodyDef = BodyDef(position: initialPosition, userData: this);
return world.createBody(bodyDef)..createFixtureFromShape(shape); return world.createBody(bodyDef)..createFixtureFromShape(shape);
} }
} }

@ -65,7 +65,7 @@ class SparkyComputer extends BodyComponent {
..setAsBox( ..setAsBox(
1, 1,
0.1, 0.1,
Vector2(-13.2, -49.9), Vector2(-13.1, -49.7),
-0.18, -0.18,
); );

@ -1,6 +1,4 @@
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/stories/ball/basic_ball_game.dart'; import 'package:sandbox/stories/ball/basic_ball_game.dart';
@ -23,16 +21,6 @@ class FlipperGame extends BallGame with KeyboardEvents {
- Press right arrow key or "D" to move the right flipper. - Press right arrow key or "D" to move the right flipper.
'''; ''';
static const _leftFlipperKeys = [
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.keyA,
];
static const _rightFlipperKeys = [
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.keyD,
];
late Flipper leftFlipper; late Flipper leftFlipper;
late Flipper rightFlipper; late Flipper rightFlipper;
@ -50,32 +38,4 @@ class FlipperGame extends BallGame with KeyboardEvents {
await traceAllBodies(); await traceAllBodies();
} }
@override
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
final movedLeftFlipper = _leftFlipperKeys.contains(event.logicalKey);
if (movedLeftFlipper) {
if (event is RawKeyDownEvent) {
leftFlipper.moveUp();
} else if (event is RawKeyUpEvent) {
leftFlipper.moveDown();
}
}
final movedRightFlipper = _rightFlipperKeys.contains(event.logicalKey);
if (movedRightFlipper) {
if (event is RawKeyDownEvent) {
rightFlipper.moveUp();
} else if (event is RawKeyUpEvent) {
rightFlipper.moveDown();
}
}
return movedLeftFlipper || movedRightFlipper
? KeyEventResult.handled
: KeyEventResult.ignored;
}
} }

@ -0,0 +1,53 @@
// 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_theme/pinball_theme.dart' as theme;
import '../../../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group(
'BallImpulsingBehavior',
() {
final asset = theme.Assets.images.dash.ball.keyName;
final flameTester = FlameTester(() => TestGame([asset]));
test('can be instantiated', () {
expect(
BallImpulsingBehavior(impulse: Vector2.zero()),
isA<BallImpulsingBehavior>(),
);
});
flameTester.test(
'impulses the ball with the given velocity when loaded '
'and then removes itself',
(game) async {
final ball = Ball.test();
await game.ensureAdd(ball);
final impulse = Vector2.all(1);
final behavior = BallImpulsingBehavior(impulse: impulse);
await ball.ensureAdd(behavior);
expect(
ball.body.linearVelocity.x,
equals(impulse.x),
);
expect(
ball.body.linearVelocity.y,
equals(impulse.y),
);
expect(
game.descendants().whereType<BallImpulsingBehavior>().isEmpty,
isTrue,
);
},
);
},
);
}

@ -0,0 +1,38 @@
// ignore_for_file: cascade_invocations
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/src/components/components.dart';
import '../../../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FlipperJointingBehavior', () {
final flameTester = FlameTester(TestGame.new);
test('can be instantiated', () {
expect(
FlipperJointingBehavior(),
isA<FlipperJointingBehavior>(),
);
});
flameTester.test('can be loaded', (game) async {
final behavior = FlipperJointingBehavior();
final parent = Flipper.test(side: BoardSide.left);
await game.ensureAdd(parent);
await parent.ensureAdd(behavior);
expect(parent.contains(behavior), isTrue);
});
flameTester.test('creates a joint', (game) async {
final behavior = FlipperJointingBehavior();
final parent = Flipper.test(side: BoardSide.left);
await game.ensureAdd(parent);
await parent.ensureAdd(behavior);
expect(parent.body.joints, isNotEmpty);
});
});
}

@ -0,0 +1,357 @@
// ignore_for_file: cascade_invocations
import 'package:flame_test/flame_test.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../../../helpers/helpers.dart';
class _MockRawKeyDownEvent extends Mock implements RawKeyDownEvent {
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return super.toString();
}
}
class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent {
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return super.toString();
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FlipperKeyControllingBehavior', () {
final flameTester = FlameTester(TestGame.new);
group(
'onKeyEvent',
() {
late Flipper rightFlipper;
late Flipper leftFlipper;
setUp(() {
rightFlipper = Flipper.test(side: BoardSide.right);
leftFlipper = Flipper.test(side: BoardSide.left);
});
group('on right Flipper', () {
flameTester.test(
'moves upwards when right arrow is pressed',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowRight,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isNegative);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'moves downwards when right arrow is released',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowRight,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isPositive);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'moves upwards when D is pressed',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyD,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isNegative);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'moves downwards when D is released',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyD,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isPositive);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
group("doesn't move when", () {
flameTester.test(
'left arrow is pressed',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowLeft,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isZero);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'left arrow is released',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowLeft,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isZero);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'A is pressed',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyA,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isZero);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'A is released',
(game) async {
await game.ensureAdd(rightFlipper);
final behavior = FlipperKeyControllingBehavior();
await rightFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyA,
);
behavior.onKeyEvent(event, {});
expect(rightFlipper.body.linearVelocity.y, isZero);
expect(rightFlipper.body.linearVelocity.x, isZero);
},
);
});
});
group('on left Flipper', () {
flameTester.test(
'moves upwards when left arrow is pressed',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowLeft,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isNegative);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'moves downwards when left arrow is released',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowLeft,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isPositive);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'moves upwards when A is pressed',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyA,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isNegative);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'moves downwards when A is released',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyA,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isPositive);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
group("doesn't move when", () {
flameTester.test(
'right arrow is pressed',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowRight,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isZero);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'right arrow is released',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.arrowRight,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isZero);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'D is pressed',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyDownEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyD,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isZero);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
flameTester.test(
'D is released',
(game) async {
await game.ensureAdd(leftFlipper);
final behavior = FlipperKeyControllingBehavior();
await leftFlipper.ensureAdd(behavior);
final event = _MockRawKeyUpEvent();
when(() => event.logicalKey).thenReturn(
LogicalKeyboardKey.keyD,
);
behavior.onKeyEvent(event, {});
expect(leftFlipper.body.linearVelocity.y, isZero);
expect(leftFlipper.body.linearVelocity.x, isZero);
},
);
});
});
},
);
});
}

@ -6,7 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme; import 'package:pinball_theme/pinball_theme.dart' as theme;
import '../../helpers/helpers.dart'; import '../../../helpers/helpers.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@ -36,7 +36,7 @@ void main() {
verify: (game, tester) async { verify: (game, tester) async {
await expectLater( await expectLater(
find.byGame<TestGame>(), find.byGame<TestGame>(),
matchesGoldenFile('golden/flipper.png'), matchesGoldenFile('../golden/flipper.png'),
); );
}, },
); );

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

@ -2,6 +2,7 @@
import 'package:bloc_test/bloc_test.dart'; import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart'; import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@ -12,6 +13,12 @@ import '../../../helpers/helpers.dart';
class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {} class _MockSpaceshipRampCubit extends Mock implements SpaceshipRampCubit {}
class _MockBall extends Mock implements Ball {}
class _MockContact extends Mock implements Contact {}
class _MockManifold extends Mock implements Manifold {}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final assets = [ final assets = [
@ -275,4 +282,46 @@ void main() {
}); });
}); });
}); });
group('SpaceshipRampBase', () {
test('can be instantiated', () {
expect(SpaceshipRampBase(), isA<SpaceshipRampBase>());
});
flameTester.test('can be loaded', (game) async {
final component = SpaceshipRampBase();
await game.ensureAdd(component);
expect(game.children, contains(component));
});
flameTester.test(
'postSolves disables contact when ball is not on Layer.board',
(game) async {
final ball = _MockBall();
final contact = _MockContact();
when(() => ball.layer).thenReturn(Layer.spaceshipEntranceRamp);
final component = SpaceshipRampBase();
await game.ensureAdd(component);
component.preSolve(ball, contact, _MockManifold());
verify(() => contact.setEnabled(false)).called(1);
},
);
flameTester.test(
'postSolves enables contact when ball is on Layer.board',
(game) async {
final ball = _MockBall();
final contact = _MockContact();
when(() => ball.layer).thenReturn(Layer.board);
final component = SpaceshipRampBase();
await game.ensureAdd(component);
component.preSolve(ball, contact, _MockManifold());
verify(() => contact.setEnabled(true)).called(1);
},
);
});
} }

@ -6,15 +6,20 @@ import 'package:pinball_flame/pinball_flame.dart';
/// {@endtemplate} /// {@endtemplate}
class LayerContactBehavior extends ContactBehavior<BodyComponent> { class LayerContactBehavior extends ContactBehavior<BodyComponent> {
/// {@macro layer_contact_behavior} /// {@macro layer_contact_behavior}
LayerContactBehavior({required Layer layer}) : _layer = layer; LayerContactBehavior({
required Layer layer,
final Layer _layer; bool onBegin = true,
}) {
if (onBegin) {
onBeginContact = (other, _) => _changeLayer(other, layer);
} else {
onEndContact = (other, _) => _changeLayer(other, layer);
}
}
@override void _changeLayer(Object other, Layer layer) {
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! Layered) return; if (other is! Layered) return;
if (other.layer == _layer) return; if (other.layer == layer) return;
other.layer = _layer; other.layer = layer;
} }
} }

@ -6,15 +6,20 @@ import 'package:pinball_flame/pinball_flame.dart';
/// {@endtemplate} /// {@endtemplate}
class ZIndexContactBehavior extends ContactBehavior<BodyComponent> { class ZIndexContactBehavior extends ContactBehavior<BodyComponent> {
/// {@macro layer_contact_behavior} /// {@macro layer_contact_behavior}
ZIndexContactBehavior({required int zIndex}) : _zIndex = zIndex; ZIndexContactBehavior({
required int zIndex,
final int _zIndex; bool onBegin = true,
}) {
if (onBegin) {
onBeginContact = (other, _) => _changeZIndex(other, zIndex);
} else {
onEndContact = (other, _) => _changeZIndex(other, zIndex);
}
}
@override void _changeZIndex(Object other, int zIndex) {
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! ZIndex) return; if (other is! ZIndex) return;
if (other.zIndex == _zIndex) return; if (other.zIndex == zIndex) return;
other.zIndex = _zIndex; other.zIndex = zIndex;
} }
} }

@ -1,4 +1,5 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// The signature for a key handle function /// The signature for a key handle function
@ -18,6 +19,17 @@ class KeyboardInputController extends Component with KeyboardHandler {
final Map<LogicalKeyboardKey, KeyHandlerCallback> _keyUp; final Map<LogicalKeyboardKey, KeyHandlerCallback> _keyUp;
final Map<LogicalKeyboardKey, KeyHandlerCallback> _keyDown; final Map<LogicalKeyboardKey, KeyHandlerCallback> _keyDown;
/// Trigger a virtual key up event.
bool onVirtualKeyUp(LogicalKeyboardKey key) {
final handler = _keyUp[key];
if (handler != null) {
return handler();
}
return true;
}
@override @override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) { bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
final isUp = event is RawKeyUpEvent; final isUp = event is RawKeyUpEvent;
@ -32,3 +44,18 @@ class KeyboardInputController extends Component with KeyboardHandler {
return true; return true;
} }
} }
/// Add the ability to virtually trigger key events to a [FlameGame]'s
/// [KeyboardInputController].
extension VirtualKeyEvents on FlameGame {
/// Trigger a key up
void triggerVirtualKeyUp(LogicalKeyboardKey key) {
final keyControllers = descendants().whereType<KeyboardInputController>();
for (final controller in keyControllers) {
if (!controller.onVirtualKeyUp(key)) {
break;
}
}
}
}

@ -56,5 +56,23 @@ void main() {
expect(component.layer, newLayer); expect(component.layer, newLayer);
}); });
flameTester.test('endContact changes layer', (game) async {
const oldLayer = Layer.all;
const newLayer = Layer.board;
final behavior = LayerContactBehavior(
layer: newLayer,
onBegin: false,
);
final parent = _TestBodyComponent();
await game.ensureAdd(parent);
await parent.ensureAdd(behavior);
final component = _TestLayeredBodyComponent(layer: oldLayer);
behavior.endContact(component, _MockContact());
expect(component.layer, newLayer);
});
}); });
} }

@ -56,5 +56,20 @@ void main() {
expect(component.zIndex, newIndex); expect(component.zIndex, newIndex);
}); });
flameTester.test('endContact changes zIndex', (game) async {
const oldIndex = 0;
const newIndex = 1;
final behavior = ZIndexContactBehavior(zIndex: newIndex, onBegin: false);
final parent = _TestBodyComponent();
await game.ensureAdd(parent);
await parent.ensureAdd(behavior);
final component = _TestZIndexBodyComponent(zIndex: oldIndex);
behavior.endContact(component, _MockContact());
expect(component.zIndex, newIndex);
});
}); });
} }

@ -1,11 +1,36 @@
// ignore_for_file: cascade_invocations, one_member_abstracts // ignore_for_file: cascade_invocations, one_member_abstracts
import 'package:flame/game.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
class _TestGame extends FlameGame {
bool pressed = false;
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(
KeyboardInputController(
keyUp: {
LogicalKeyboardKey.enter: () {
pressed = true;
return true;
},
LogicalKeyboardKey.escape: () {
return false;
},
},
),
);
}
}
abstract class _KeyCall { abstract class _KeyCall {
bool onCall(); bool onCall();
} }
@ -75,4 +100,15 @@ void main() {
}, },
); );
}); });
group('VirtualKeyEvents', () {
final flameTester = FlameTester(_TestGame.new);
group('onVirtualKeyUp', () {
flameTester.test('triggers the event', (game) async {
await game.ready();
game.triggerVirtualKeyUp(LogicalKeyboardKey.enter);
expect(game.pressed, isTrue);
});
});
});
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -3,8 +3,6 @@
/// FlutterGen /// FlutterGen
/// ***************************************************** /// *****************************************************
// ignore_for_file: directives_ordering,unnecessary_import
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class $AssetsImagesGen { class $AssetsImagesGen {
@ -17,7 +15,14 @@ class $AssetsImagesGen {
class $AssetsImagesButtonGen { class $AssetsImagesButtonGen {
const $AssetsImagesButtonGen(); const $AssetsImagesButtonGen();
/// File path: assets/images/button/pinball_button.png AssetGenImage get dpadDown =>
const AssetGenImage('assets/images/button/dpad_down.png');
AssetGenImage get dpadLeft =>
const AssetGenImage('assets/images/button/dpad_left.png');
AssetGenImage get dpadRight =>
const AssetGenImage('assets/images/button/dpad_right.png');
AssetGenImage get dpadUp =>
const AssetGenImage('assets/images/button/dpad_up.png');
AssetGenImage get pinballButton => AssetGenImage get pinballButton =>
const AssetGenImage('assets/images/button/pinball_button.png'); const AssetGenImage('assets/images/button/pinball_button.png');
} }
@ -25,7 +30,6 @@ class $AssetsImagesButtonGen {
class $AssetsImagesDialogGen { class $AssetsImagesDialogGen {
const $AssetsImagesDialogGen(); const $AssetsImagesDialogGen();
/// File path: assets/images/dialog/background.png
AssetGenImage get background => AssetGenImage get background =>
const AssetGenImage('assets/images/dialog/background.png'); const AssetGenImage('assets/images/dialog/background.png');
} }

@ -3,14 +3,9 @@
/// FlutterGen /// FlutterGen
/// ***************************************************** /// *****************************************************
// ignore_for_file: directives_ordering,unnecessary_import
class FontFamily { class FontFamily {
FontFamily._(); FontFamily._();
/// Font family: PixeloidMono
static const String pixeloidMono = 'PixeloidMono'; static const String pixeloidMono = 'PixeloidMono';
/// Font family: PixeloidSans
static const String pixeloidSans = 'PixeloidSans'; static const String pixeloidSans = 'PixeloidSans';
} }

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:pinball_ui/gen/gen.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// Enum with all possibile directions of a [PinballDpadButton].
enum PinballDpadDirection {
/// Up
up,
/// Down
down,
/// Left
left,
/// Right
right,
}
extension _PinballDpadDirectionX on PinballDpadDirection {
String toAsset() {
switch (this) {
case PinballDpadDirection.up:
return Assets.images.button.dpadUp.keyName;
case PinballDpadDirection.down:
return Assets.images.button.dpadDown.keyName;
case PinballDpadDirection.left:
return Assets.images.button.dpadLeft.keyName;
case PinballDpadDirection.right:
return Assets.images.button.dpadRight.keyName;
}
}
}
/// {@template pinball_dpad_button}
/// Widget that renders a Dpad button with a given direction.
/// {@endtemplate}
class PinballDpadButton extends StatelessWidget {
/// {@macro pinball_dpad_button}
const PinballDpadButton({
Key? key,
required this.direction,
required this.onTap,
}) : super(key: key);
/// Which [PinballDpadDirection] this button is.
final PinballDpadDirection direction;
/// The function executed when the button is pressed.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: PinballColors.transparent,
child: InkWell(
onTap: onTap,
child: Image.asset(
direction.toAsset(),
width: 60,
height: 60,
),
),
);
}
}

@ -1,4 +1,5 @@
export 'animated_ellipsis_text.dart'; export 'animated_ellipsis_text.dart';
export 'crt_background.dart'; export 'crt_background.dart';
export 'pinball_button.dart'; export 'pinball_button.dart';
export 'pinball_dpad_button.dart';
export 'pinball_loading_indicator.dart'; export 'pinball_loading_indicator.dart';

@ -0,0 +1,122 @@
// ignore_for_file: one_member_abstracts
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_ui/gen/gen.dart';
import 'package:pinball_ui/pinball_ui.dart';
extension _WidgetTesterX on WidgetTester {
Future<void> pumpButton({
required PinballDpadDirection direction,
required VoidCallback onTap,
}) async {
await pumpWidget(
MaterialApp(
home: Scaffold(
body: PinballDpadButton(
direction: direction,
onTap: onTap,
),
),
),
);
}
}
extension _CommonFindersX on CommonFinders {
Finder byImagePath(String path) {
return find.byWidgetPredicate(
(widget) {
if (widget is Image) {
final image = widget.image;
if (image is AssetImage) {
return image.keyName == path;
}
return false;
}
return false;
},
);
}
}
abstract class _VoidCallbackStubBase {
void onCall();
}
class _VoidCallbackStub extends Mock implements _VoidCallbackStubBase {}
void main() {
group('PinballDpadButton', () {
testWidgets('can be tapped', (tester) async {
final stub = _VoidCallbackStub();
await tester.pumpButton(
direction: PinballDpadDirection.up,
onTap: stub.onCall,
);
await tester.tap(find.byType(Image));
verify(stub.onCall).called(1);
});
group('up', () {
testWidgets('renders the correct image', (tester) async {
await tester.pumpButton(
direction: PinballDpadDirection.up,
onTap: () {},
);
expect(
find.byImagePath(Assets.images.button.dpadUp.keyName),
findsOneWidget,
);
});
});
group('down', () {
testWidgets('renders the correct image', (tester) async {
await tester.pumpButton(
direction: PinballDpadDirection.down,
onTap: () {},
);
expect(
find.byImagePath(Assets.images.button.dpadDown.keyName),
findsOneWidget,
);
});
});
group('left', () {
testWidgets('renders the correct image', (tester) async {
await tester.pumpButton(
direction: PinballDpadDirection.left,
onTap: () {},
);
expect(
find.byImagePath(Assets.images.button.dpadLeft.keyName),
findsOneWidget,
);
});
});
group('right', () {
testWidgets('renders the correct image', (tester) async {
await tester.pumpButton(
direction: PinballDpadDirection.right,
onTap: () {},
);
expect(
find.byImagePath(Assets.images.button.dpadRight.keyName),
findsOneWidget,
);
});
});
});
}

@ -0,0 +1,61 @@
// ignore_for_file: cascade_invocations
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/forge2d_game.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme;
class _TestGame extends Forge2DGame {
@override
Future<void> onLoad() async {
images.prefix = '';
await images.loadAll([
theme.Assets.images.dash.ball.keyName,
]);
}
Future<void> pump(BonusBallSpawningBehavior child) async {
await ensureAdd(
FlameBlocProvider<CharacterThemeCubit, CharacterThemeState>.value(
value: CharacterThemeCubit(),
children: [
ZCanvasComponent(
children: [child],
),
],
),
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FlutterForestBonusBehavior', () {
final flameTester = FlameTester(_TestGame.new);
flameTester.test(
'adds a ball with a BallImpulsingBehavior to the game onTick '
'resulting in a -40 x impulse',
(game) async {
await game.onLoad();
final behavior = BonusBallSpawningBehavior();
await game.pump(behavior);
game.update(behavior.timer.limit);
await game.ready();
final ball = game.descendants().whereType<Ball>().single;
expect(ball.body.linearVelocity.x, equals(-40));
expect(ball.body.linearVelocity.y, equals(0));
},
);
});
}

@ -126,7 +126,7 @@ void main() {
await game.pump(behavior, player: player, bloc: bloc); await game.pump(behavior, player: player, bloc: bloc);
}, },
verify: (_, __) async { verify: (_, __) async {
verifyNever(() => player.play(any())); verify(() => player.play(PinballAudio.dino)).called(1);
}, },
); );
@ -151,7 +151,7 @@ void main() {
await game.pump(behavior, player: player, bloc: bloc); await game.pump(behavior, player: player, bloc: bloc);
}, },
verify: (_, __) async { verify: (_, __) async {
verifyNever(() => player.play(any())); verify(() => player.play(PinballAudio.android)).called(1);
}, },
); );
@ -176,7 +176,7 @@ void main() {
await game.pump(behavior, player: player, bloc: bloc); await game.pump(behavior, player: player, bloc: bloc);
}, },
verify: (_, __) async { verify: (_, __) async {
verifyNever(() => player.play(any())); verify(() => player.play(PinballAudio.dash)).called(1);
}, },
); );
}); });

@ -19,6 +19,7 @@ import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme; import 'package:pinball_theme/pinball_theme.dart' as theme;
import 'package:platform_helper/platform_helper.dart';
class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents { class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents {
final character = theme.DashTheme(); final character = theme.DashTheme();
@ -64,6 +65,8 @@ RawKeyUpEvent _mockKeyUp(LogicalKeyboardKey key) {
return event; return event;
} }
class _MockPlatformHelper extends Mock implements PlatformHelper {}
class _MockBackboxBloc extends Mock implements BackboxBloc {} class _MockBackboxBloc extends Mock implements BackboxBloc {}
class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { class _MockLeaderboardRepository extends Mock implements LeaderboardRepository {
@ -104,33 +107,29 @@ void main() {
final flameTester = FlameTester(_TestGame.new); final flameTester = FlameTester(_TestGame.new);
late BackboxBloc bloc; late BackboxBloc bloc;
late PlatformHelper platformHelper;
setUp(() { setUp(() {
bloc = _MockBackboxBloc(); bloc = _MockBackboxBloc();
platformHelper = _MockPlatformHelper();
whenListen( whenListen(
bloc, bloc,
Stream<BackboxState>.empty(), Stream<BackboxState>.empty(),
initialState: LoadingState(), initialState: LoadingState(),
); );
when(() => platformHelper.isMobile).thenReturn(false);
}); });
group('Backbox', () { group('Backbox', () {
flameTester.test( flameTester.test(
'loads correctly', 'loads correctly',
(game) async { (game) async {
final backbox = Backbox.test(bloc: bloc); final backbox = Backbox.test(
await game.pump(backbox); bloc: bloc,
expect(game.descendants(), contains(backbox)); platformHelper: platformHelper,
},
); );
flameTester.test(
'adds LeaderboardRequested when loaded',
(game) async {
final backbox = Backbox.test(bloc: bloc);
await game.pump(backbox); await game.pump(backbox);
expect(game.descendants(), contains(backbox));
verify(() => bloc.add(LeaderboardRequested())).called(1);
}, },
); );
@ -142,7 +141,10 @@ void main() {
..followVector2(Vector2(0, -130)) ..followVector2(Vector2(0, -130))
..zoom = 6; ..zoom = 6;
await game.pump( await game.pump(
Backbox.test(bloc: bloc), Backbox.test(
bloc: bloc,
platformHelper: platformHelper,
),
); );
await tester.pump(); await tester.pump();
}, },
@ -160,7 +162,9 @@ void main() {
final backbox = Backbox.test( final backbox = Backbox.test(
bloc: BackboxBloc( bloc: BackboxBloc(
leaderboardRepository: _MockLeaderboardRepository(), leaderboardRepository: _MockLeaderboardRepository(),
initialEntries: [LeaderboardEntryData.empty],
), ),
platformHelper: platformHelper,
); );
await game.pump(backbox); await game.pump(backbox);
backbox.requestInitials( backbox.requestInitials(
@ -189,7 +193,10 @@ void main() {
Stream<BackboxState>.empty(), Stream<BackboxState>.empty(),
initialState: state, initialState: state,
); );
final backbox = Backbox.test(bloc: bloc); final backbox = Backbox.test(
bloc: bloc,
platformHelper: platformHelper,
);
await game.pump(backbox); await game.pump(backbox);
game.onKeyEvent(_mockKeyUp(LogicalKeyboardKey.enter), {}); game.onKeyEvent(_mockKeyUp(LogicalKeyboardKey.enter), {});
@ -205,6 +212,34 @@ void main() {
}, },
); );
flameTester.test(
'adds the mobile controls overlay when platform is mobile',
(game) async {
final bloc = _MockBackboxBloc();
final platformHelper = _MockPlatformHelper();
final state = InitialsFormState(
score: 10,
character: game.character,
);
whenListen(
bloc,
Stream<BackboxState>.empty(),
initialState: state,
);
when(() => platformHelper.isMobile).thenReturn(true);
final backbox = Backbox.test(
bloc: bloc,
platformHelper: platformHelper,
);
await game.pump(backbox);
expect(
game.overlays.value,
contains(PinballGame.mobileControlsOverlay),
);
},
);
flameTester.test( flameTester.test(
'adds InitialsSubmissionSuccessDisplay on InitialsSuccessState', 'adds InitialsSubmissionSuccessDisplay on InitialsSuccessState',
(game) async { (game) async {
@ -213,7 +248,10 @@ void main() {
Stream<BackboxState>.empty(), Stream<BackboxState>.empty(),
initialState: InitialsSuccessState(), initialState: InitialsSuccessState(),
); );
final backbox = Backbox.test(bloc: bloc); final backbox = Backbox.test(
bloc: bloc,
platformHelper: platformHelper,
);
await game.pump(backbox); await game.pump(backbox);
expect( expect(
@ -234,7 +272,10 @@ void main() {
Stream<BackboxState>.empty(), Stream<BackboxState>.empty(),
initialState: InitialsFailureState(), initialState: InitialsFailureState(),
); );
final backbox = Backbox.test(bloc: bloc); final backbox = Backbox.test(
bloc: bloc,
platformHelper: platformHelper,
);
await game.pump(backbox); await game.pump(backbox);
expect( expect(
@ -256,7 +297,10 @@ void main() {
initialState: LeaderboardSuccessState(entries: const []), initialState: LeaderboardSuccessState(entries: const []),
); );
final backbox = Backbox.test(bloc: bloc); final backbox = Backbox.test(
bloc: bloc,
platformHelper: platformHelper,
);
await game.pump(backbox); await game.pump(backbox);
expect( expect(
@ -276,7 +320,10 @@ void main() {
initialState: LoadingState(), initialState: LoadingState(),
); );
final backbox = Backbox.test(bloc: bloc); final backbox = Backbox.test(
bloc: bloc,
platformHelper: platformHelper,
);
await game.pump(backbox); await game.pump(backbox);
backbox.removeFromParent(); backbox.removeFromParent();

@ -12,14 +12,37 @@ class _MockLeaderboardRepository extends Mock implements LeaderboardRepository {
void main() { void main() {
late LeaderboardRepository leaderboardRepository; late LeaderboardRepository leaderboardRepository;
const emptyEntries = <LeaderboardEntryData>[];
const filledEntries = [LeaderboardEntryData.empty];
group('BackboxBloc', () { group('BackboxBloc', () {
test('inits state with LeaderboardSuccessState when has entries', () {
leaderboardRepository = _MockLeaderboardRepository();
final bloc = BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: filledEntries,
);
expect(bloc.state, isA<LeaderboardSuccessState>());
});
test('inits state with LeaderboardFailureState when has no entries', () {
leaderboardRepository = _MockLeaderboardRepository();
final bloc = BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: null,
);
expect(bloc.state, isA<LeaderboardFailureState>());
});
blocTest<BackboxBloc, BackboxState>( blocTest<BackboxBloc, BackboxState>(
'adds InitialsFormState on PlayerInitialsRequested', 'adds InitialsFormState on PlayerInitialsRequested',
setUp: () { setUp: () {
leaderboardRepository = _MockLeaderboardRepository(); leaderboardRepository = _MockLeaderboardRepository();
}, },
build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), build: () => BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: emptyEntries,
),
act: (bloc) => bloc.add( act: (bloc) => bloc.add(
PlayerInitialsRequested( PlayerInitialsRequested(
score: 100, score: 100,
@ -46,7 +69,10 @@ void main() {
), ),
).thenAnswer((_) async {}); ).thenAnswer((_) async {});
}, },
build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), build: () => BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: emptyEntries,
),
act: (bloc) => bloc.add( act: (bloc) => bloc.add(
PlayerInitialsSubmitted( PlayerInitialsSubmitted(
score: 10, score: 10,
@ -74,7 +100,10 @@ void main() {
), ),
).thenThrow(Exception('Error')); ).thenThrow(Exception('Error'));
}, },
build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), build: () => BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: emptyEntries,
),
act: (bloc) => bloc.add( act: (bloc) => bloc.add(
PlayerInitialsSubmitted( PlayerInitialsSubmitted(
score: 10, score: 10,
@ -100,7 +129,10 @@ void main() {
(_) async => [LeaderboardEntryData.empty], (_) async => [LeaderboardEntryData.empty],
); );
}, },
build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), build: () => BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: emptyEntries,
),
act: (bloc) => bloc.add(LeaderboardRequested()), act: (bloc) => bloc.add(LeaderboardRequested()),
expect: () => [ expect: () => [
LoadingState(), LoadingState(),
@ -116,7 +148,10 @@ void main() {
() => leaderboardRepository.fetchTop10Leaderboard(), () => leaderboardRepository.fetchTop10Leaderboard(),
).thenThrow(Exception('Error')); ).thenThrow(Exception('Error'));
}, },
build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), build: () => BackboxBloc(
leaderboardRepository: leaderboardRepository,
initialEntries: emptyEntries,
),
act: (bloc) => bloc.add(LeaderboardRequested()), act: (bloc) => bloc.add(LeaderboardRequested()),
expect: () => [ expect: () => [
LoadingState(), LoadingState(),

@ -1,260 +0,0 @@
import 'dart:collection';
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/input.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter/services.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';
class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents {
@override
Future<void> onLoad() async {
images.prefix = '';
await images.loadAll([
Assets.images.flipper.left.keyName,
Assets.images.flipper.right.keyName,
]);
}
Future<void> pump(Flipper flipper, {required GameBloc gameBloc}) {
return ensureAdd(
FlameBlocProvider<GameBloc, GameState>.value(
value: gameBloc,
children: [flipper],
),
);
}
}
class _MockGameBloc extends Mock implements GameBloc {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(_TestGame.new);
group('FlipperController', () {
late GameBloc gameBloc;
setUp(() {
gameBloc = _MockGameBloc();
});
group('onKeyEvent', () {
final leftKeys = UnmodifiableListView([
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.keyA,
]);
final rightKeys = UnmodifiableListView([
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.keyD,
]);
group('and Flipper is left', () {
late Flipper flipper;
late FlipperController controller;
setUp(() {
flipper = Flipper(side: BoardSide.left);
controller = FlipperController(flipper);
flipper.add(controller);
});
testRawKeyDownEvents(leftKeys, (event) {
flameTester.test(
'moves upwards '
'when ${event.logicalKey.keyLabel} is pressed',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial().copyWith(
status: GameStatus.playing,
),
);
await game.ready();
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isNegative);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
testRawKeyDownEvents(leftKeys, (event) {
flameTester.test(
'does nothing when is game over',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial().copyWith(
status: GameStatus.gameOver,
),
);
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isZero);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
testRawKeyUpEvents(leftKeys, (event) {
flameTester.test(
'moves downwards '
'when ${event.logicalKey.keyLabel} is released',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial().copyWith(
status: GameStatus.playing,
),
);
await game.ready();
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isPositive);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
testRawKeyUpEvents(rightKeys, (event) {
flameTester.test(
'does nothing '
'when ${event.logicalKey.keyLabel} is released',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial(),
);
await game.ready();
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isZero);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
});
group('and Flipper is right', () {
late Flipper flipper;
late FlipperController controller;
setUp(() {
flipper = Flipper(side: BoardSide.right);
controller = FlipperController(flipper);
flipper.add(controller);
});
testRawKeyDownEvents(rightKeys, (event) {
flameTester.test(
'moves upwards '
'when ${event.logicalKey.keyLabel} is pressed',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial().copyWith(
status: GameStatus.playing,
),
);
await game.ready();
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isNegative);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
testRawKeyUpEvents(rightKeys, (event) {
flameTester.test(
'moves downwards '
'when ${event.logicalKey.keyLabel} is released',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial().copyWith(
status: GameStatus.playing,
),
);
await game.ready();
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isPositive);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
testRawKeyDownEvents(rightKeys, (event) {
flameTester.test(
'does nothing when is game over',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial().copyWith(
status: GameStatus.gameOver,
),
);
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isZero);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
testRawKeyUpEvents(leftKeys, (event) {
flameTester.test(
'does nothing '
'when ${event.logicalKey.keyLabel} is released',
(game) async {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial().copyWith(
status: GameStatus.playing,
),
);
await game.ready();
await game.pump(flipper, gameBloc: gameBloc);
controller.onKeyEvent(event, {});
expect(flipper.body.linearVelocity.y, isZero);
expect(flipper.body.linearVelocity.x, isZero);
},
);
});
});
});
});
}

@ -5,9 +5,9 @@ import 'package:flame_forge2d/forge2d_game.dart';
import 'package:flame_test/flame_test.dart'; import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart'; import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme; import 'package:pinball_theme/pinball_theme.dart' as theme;
@ -27,13 +27,8 @@ class _TestGame extends Forge2DGame {
required GameBloc gameBloc, required GameBloc gameBloc,
}) async { }) async {
await ensureAdd( await ensureAdd(
FlameMultiBlocProvider( FlameBlocProvider<GameBloc, GameState>.value(
providers: [ value: gameBloc,
FlameBlocProvider<GameBloc, GameState>.value(value: gameBloc),
FlameBlocProvider<CharacterThemeCubit, CharacterThemeState>.value(
value: CharacterThemeCubit(),
),
],
children: [ children: [
ZCanvasComponent( ZCanvasComponent(
children: [child], children: [child],
@ -94,7 +89,7 @@ void main() {
); );
flameTester.testGameWidget( flameTester.testGameWidget(
'adds a new Ball to the game ' 'adds BonusBallSpawningBehavior to the game '
'when bumpers are activated three times', 'when bumpers are activated three times',
setUp: (game, tester) async { setUp: (game, tester) async {
await game.onLoad(); await game.onLoad();
@ -121,7 +116,7 @@ void main() {
await game.ready(); await game.ready();
expect( expect(
game.descendants().whereType<Ball>().length, game.descendants().whereType<BonusBallSpawningBehavior>().length,
equals(1), equals(1),
); );
}, },

@ -8,16 +8,24 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme;
class _TestGame extends Forge2DGame { class _TestGame extends Forge2DGame {
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
images.prefix = ''; images.prefix = '';
await images.load(Assets.images.backbox.marquee.keyName); await images.loadAll(
[
const theme.DashTheme().leaderboardIcon.keyName,
Assets.images.backbox.marquee.keyName,
Assets.images.backbox.displayDivider.keyName,
],
);
} }
Future<void> pump( Future<void> pump(
@ -35,8 +43,15 @@ class _TestGame extends Forge2DGame {
), ),
], ],
children: [ children: [
MultiFlameProvider(
providers: [
FlameProvider<PinballPlayer>.value( FlameProvider<PinballPlayer>.value(
pinballPlayer ?? _MockPinballPlayer(), pinballPlayer ?? _MockPinballPlayer(),
),
FlameProvider<AppLocalizations>.value(
_MockAppLocalizations(),
),
],
children: children, children: children,
), ),
], ],
@ -50,6 +65,35 @@ class _MockPinballPlayer extends Mock implements PinballPlayer {}
class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { class _MockLeaderboardRepository extends Mock implements LeaderboardRepository {
} }
class _MockAppLocalizations extends Mock implements AppLocalizations {
@override
String get score => '';
@override
String get name => '';
@override
String get rank => '';
@override
String get enterInitials => '';
@override
String get arrows => '';
@override
String get andPress => '';
@override
String get enterReturn => '';
@override
String get toSubmit => '';
@override
String get loading => '';
}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@ -92,15 +136,23 @@ void main() {
}); });
group('onNewState', () { group('onNewState', () {
group('on game over', () {
late GameState state;
setUp(() {
state = const GameState.initial().copyWith(
status: GameStatus.gameOver,
);
});
flameTester.test( flameTester.test(
'changes the backbox display when the game is over', 'changes the backbox display',
(game) async { (game) async {
final component = GameBlocStatusListener(); final component = GameBlocStatusListener();
final repository = _MockLeaderboardRepository(); final repository = _MockLeaderboardRepository();
final backbox = Backbox(leaderboardRepository: repository); final backbox = Backbox(
final state = const GameState.initial() leaderboardRepository: repository,
..copyWith( entries: const [],
status: GameStatus.gameOver,
); );
await game.pump([component, backbox]); await game.pump([component, backbox]);
@ -109,6 +161,61 @@ void main() {
}, },
); );
flameTester.test(
'removes FlipperKeyControllingBehavior from Fipper',
(game) async {
final component = GameBlocStatusListener();
final repository = _MockLeaderboardRepository();
final backbox = Backbox(
leaderboardRepository: repository,
entries: const [],
);
final flipper = Flipper.test(side: BoardSide.left);
final behavior = FlipperKeyControllingBehavior();
await game.pump([component, backbox, flipper]);
await flipper.ensureAdd(behavior);
expect(state.status, GameStatus.gameOver);
component.onNewState(state);
await game.ready();
expect(
flipper.children.whereType<FlipperKeyControllingBehavior>(),
isEmpty,
);
},
);
flameTester.test(
'plays the game over voice over',
(game) async {
final player = _MockPinballPlayer();
final component = GameBlocStatusListener();
final repository = _MockLeaderboardRepository();
final backbox = Backbox(
leaderboardRepository: repository,
entries: const [],
);
await game.pump([component, backbox], pinballPlayer: player);
component.onNewState(state);
verify(() => player.play(PinballAudio.gameOverVoiceOver)).called(1);
},
);
});
group('on playing', () {
late GameState state;
setUp(() {
state = const GameState.initial().copyWith(
status: GameStatus.playing,
);
});
flameTester.test( flameTester.test(
'plays the background music on start', 'plays the background music on start',
(game) async { (game) async {
@ -116,30 +223,38 @@ void main() {
final component = GameBlocStatusListener(); final component = GameBlocStatusListener();
await game.pump([component], pinballPlayer: player); await game.pump([component], pinballPlayer: player);
component.onNewState( expect(state.status, equals(GameStatus.playing));
const GameState.initial().copyWith(status: GameStatus.playing), component.onNewState(state);
);
verify(() => player.play(PinballAudio.backgroundMusic)).called(1); verify(() => player.play(PinballAudio.backgroundMusic)).called(1);
}, },
); );
flameTester.test( flameTester.test(
'plays the game over voice over when it is game over', 'adds key controlling behavior to Fippers when the game is started',
(game) async { (game) async {
final player = _MockPinballPlayer();
final component = GameBlocStatusListener(); final component = GameBlocStatusListener();
final repository = _MockLeaderboardRepository(); final repository = _MockLeaderboardRepository();
final backbox = Backbox(leaderboardRepository: repository); final backbox = Backbox(
await game.pump([component, backbox], pinballPlayer: player); leaderboardRepository: repository,
entries: const [],
component.onNewState(
const GameState.initial().copyWith(status: GameStatus.gameOver),
); );
final flipper = Flipper.test(side: BoardSide.left);
verify(() => player.play(PinballAudio.gameOverVoiceOver)).called(1); await game.pump([component, backbox, flipper]);
component.onNewState(state);
await game.ready();
expect(
flipper.children
.whereType<FlipperKeyControllingBehavior>()
.length,
equals(1),
);
}, },
); );
}); });
}); });
});
} }

@ -32,7 +32,10 @@ class _TestPinballGame extends PinballGame {
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
images.prefix = ''; images.prefix = '';
final futures = preLoadAssets(); final futures = [
...preLoadAssets(),
preFetchLeaderboard(),
];
await Future.wait<void>(futures); await Future.wait<void>(futures);
return super.onLoad(); return super.onLoad();
@ -335,6 +338,20 @@ void main() {
expect(game.focusNode.hasFocus, isTrue); expect(game.focusNode.hasFocus, isTrue);
}); });
testWidgets('mobile controls when the overlay is added', (tester) async {
await tester.pumpApp(
PinballGameView(game: game),
gameBloc: gameBloc,
startGameBloc: startGameBloc,
);
game.overlays.add(PinballGame.mobileControlsOverlay);
await tester.pump();
expect(find.byType(MobileControls), findsOneWidget);
});
group('info icon', () { group('info icon', () {
testWidgets('renders on game over', (tester) async { testWidgets('renders on game over', (tester) async {
final gameState = GameState.initial().copyWith( final gameState = GameState.initial().copyWith(

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_ui/pinball_ui.dart';
class _MockPinballGame extends Mock implements PinballGame {}
extension _WidgetTesterX on WidgetTester {
Future<void> pumpMobileControls(PinballGame game) async {
await pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
],
home: Scaffold(
body: MobileControls(game: game),
),
),
);
}
}
extension _CommonFindersX on CommonFinders {
Finder byPinballDpadDirection(PinballDpadDirection direction) {
return byWidgetPredicate((widget) {
return widget is PinballDpadButton && widget.direction == direction;
});
}
}
void main() {
group('MobileControls', () {
testWidgets('renders', (tester) async {
await tester.pumpMobileControls(_MockPinballGame());
expect(find.byType(PinballButton), findsOneWidget);
expect(find.byType(MobileDpad), findsOneWidget);
});
testWidgets('correctly triggers the arrow up', (tester) async {
var pressed = false;
final component = KeyboardInputController(
keyUp: {
LogicalKeyboardKey.arrowUp: () => pressed = true,
},
);
final game = _MockPinballGame();
when(game.descendants).thenReturn([component]);
await tester.pumpMobileControls(game);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.up));
await tester.pump();
expect(pressed, isTrue);
});
testWidgets('correctly triggers the arrow down', (tester) async {
var pressed = false;
final component = KeyboardInputController(
keyUp: {
LogicalKeyboardKey.arrowDown: () => pressed = true,
},
);
final game = _MockPinballGame();
when(game.descendants).thenReturn([component]);
await tester.pumpMobileControls(game);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.down));
await tester.pump();
expect(pressed, isTrue);
});
testWidgets('correctly triggers the arrow right', (tester) async {
var pressed = false;
final component = KeyboardInputController(
keyUp: {
LogicalKeyboardKey.arrowRight: () => pressed = true,
},
);
final game = _MockPinballGame();
when(game.descendants).thenReturn([component]);
await tester.pumpMobileControls(game);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.right));
await tester.pump();
expect(pressed, isTrue);
});
testWidgets('correctly triggers the arrow left', (tester) async {
var pressed = false;
final component = KeyboardInputController(
keyUp: {
LogicalKeyboardKey.arrowLeft: () => pressed = true,
},
);
final game = _MockPinballGame();
when(game.descendants).thenReturn([component]);
await tester.pumpMobileControls(game);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.left));
await tester.pump();
expect(pressed, isTrue);
});
testWidgets('correctly triggers the enter', (tester) async {
var pressed = false;
final component = KeyboardInputController(
keyUp: {
LogicalKeyboardKey.enter: () => pressed = true,
},
);
final game = _MockPinballGame();
when(game.descendants).thenReturn([component]);
await tester.pumpMobileControls(game);
await tester.tap(find.byType(PinballButton));
await tester.pump();
expect(pressed, isTrue);
});
});
}

@ -0,0 +1,113 @@
// ignore_for_file: one_member_abstracts
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_ui/pinball_ui.dart';
extension _WidgetTesterX on WidgetTester {
Future<void> pumpDpad({
required VoidCallback onTapUp,
required VoidCallback onTapDown,
required VoidCallback onTapLeft,
required VoidCallback onTapRight,
}) async {
await pumpWidget(
MaterialApp(
home: Scaffold(
body: MobileDpad(
onTapUp: onTapUp,
onTapDown: onTapDown,
onTapLeft: onTapLeft,
onTapRight: onTapRight,
),
),
),
);
}
}
extension _CommonFindersX on CommonFinders {
Finder byPinballDpadDirection(PinballDpadDirection direction) {
return byWidgetPredicate((widget) {
return widget is PinballDpadButton && widget.direction == direction;
});
}
}
abstract class _VoidCallbackStubBase {
void onCall();
}
class _VoidCallbackStub extends Mock implements _VoidCallbackStubBase {}
void main() {
group('MobileDpad', () {
testWidgets('renders correctly', (tester) async {
await tester.pumpDpad(
onTapUp: () {},
onTapDown: () {},
onTapLeft: () {},
onTapRight: () {},
);
expect(
find.byType(PinballDpadButton),
findsNWidgets(4),
);
});
testWidgets('can tap up', (tester) async {
final stub = _VoidCallbackStub();
await tester.pumpDpad(
onTapUp: stub.onCall,
onTapDown: () {},
onTapLeft: () {},
onTapRight: () {},
);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.up));
verify(stub.onCall).called(1);
});
testWidgets('can tap down', (tester) async {
final stub = _VoidCallbackStub();
await tester.pumpDpad(
onTapUp: () {},
onTapDown: stub.onCall,
onTapLeft: () {},
onTapRight: () {},
);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.down));
verify(stub.onCall).called(1);
});
testWidgets('can tap left', (tester) async {
final stub = _VoidCallbackStub();
await tester.pumpDpad(
onTapUp: () {},
onTapDown: () {},
onTapLeft: stub.onCall,
onTapRight: () {},
);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.left));
verify(stub.onCall).called(1);
});
testWidgets('can tap left', (tester) async {
final stub = _VoidCallbackStub();
await tester.pumpDpad(
onTapUp: () {},
onTapDown: () {},
onTapLeft: () {},
onTapRight: stub.onCall,
);
await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.right));
verify(stub.onCall).called(1);
});
});
}
Loading…
Cancel
Save