Merge branch 'main' into release

# Conflicts:
#	.firebaserc
#	firebase.json
release
Tom Arra 4 years ago
commit 802f3afb14

@ -11,6 +11,6 @@ jobs:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
flutter_channel: stable
flutter_version: 2.10.0
flutter_version: 2.10.5
coverage_excludes: "lib/gen/*.dart"
test_optimization: false

@ -5,7 +5,11 @@
"hosting": {
"public": "build/web",
"site": "io-pinball",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"headers": [
{
"source": "**/*.@(jpg|jpeg|gif|png)",

@ -17,7 +17,7 @@ service cloud.firestore {
}
// Leaderboard can be read if it doesn't contain any prohibited initials
allow read: if !prohibited(resource.data.playerInitials);
allow read: if isAuthedUser(request.auth);
// A leaderboard entry can be created if the user is authenticated,
// it's 3 characters long, and not a prohibited combination.

@ -0,0 +1 @@
node_modules/

@ -0,0 +1,28 @@
const functions = require("firebase-functions");
const admin = require("firebase-admin");
admin.initializeApp();
const db = admin.firestore();
exports.timedLeaderboardCleanup = functions.firestore
.document("leaderboard/{leaderboardEntry}")
.onCreate(async (_, __) => {
functions.logger.info(
"Document created, getting all leaderboard documents"
);
const snapshot = await db
.collection("leaderboard")
.orderBy("score", "desc")
.offset(10)
.get();
functions.logger.info(
`Preparing to delete ${snapshot.docs.length} documents.`
);
try {
await Promise.all(snapshot.docs.map((doc) => doc.ref.delete()));
functions.logger.info("Success");
} catch (error) {
functions.logger.error(`Failed to delete documents ${error}`);
}
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,23 @@
{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase emulators:start --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "16"
},
"main": "index.js",
"dependencies": {
"firebase-admin": "^10.0.2",
"firebase-functions": "^3.18.0"
},
"devDependencies": {
"firebase-functions-test": "^0.2.0"
},
"private": true
}

@ -39,6 +39,7 @@ class App extends StatelessWidget {
providers: [
BlocProvider(create: (_) => CharacterThemeCubit()),
BlocProvider(create: (_) => StartGameBloc()),
BlocProvider(create: (_) => GameBloc()),
],
child: MaterialApp(
title: 'I/O Pinball',

@ -1,76 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// {@template footer}
/// Footer widget with links to the main tech stack.
/// {@endtemplate}
class Footer extends StatelessWidget {
/// {@macro footer}
const Footer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(50, 0, 50, 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
_MadeWithFlutterAndFirebase(),
_GoogleIO(),
],
),
);
}
}
class _GoogleIO extends StatelessWidget {
const _GoogleIO({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return Text(
l10n.footerGoogleIOText,
style: theme.textTheme.bodyText1!.copyWith(color: PinballColors.white),
);
}
}
class _MadeWithFlutterAndFirebase extends StatelessWidget {
const _MadeWithFlutterAndFirebase({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: l10n.footerMadeWithText,
style: theme.textTheme.bodyText1!.copyWith(color: PinballColors.white),
children: <TextSpan>[
TextSpan(
text: l10n.footerFlutterLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink('https://flutter.dev'),
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
const TextSpan(text: ' & '),
TextSpan(
text: l10n.footerFirebaseLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink('https://firebase.google.com'),
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
],
),
);
}
}

@ -24,11 +24,13 @@ class BallSpawningBehavior extends Component
final plunger = gameRef.descendants().whereType<Plunger>().single;
final canvas = gameRef.descendants().whereType<ZCanvasComponent>().single;
final characterTheme = readProvider<CharacterTheme>();
final ball = ControlledBall.launch(characterTheme: characterTheme)
final ball = Ball(assetPath: characterTheme.ball.keyName)
..initialPosition = Vector2(
plunger.body.position.x,
plunger.body.position.y - Ball.size.y,
);
)
..layer = Layer.launcher
..zIndex = ZIndexes.ballOnLaunchRamp;
canvas.add(ball);
}

@ -1,4 +1,5 @@
export 'ball_spawning_behavior.dart';
export 'bonus_noise_behavior.dart';
export 'bumper_noise_behavior.dart';
export 'camera_focusing_behavior.dart';
export 'scoring_behavior.dart';

@ -0,0 +1,41 @@
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// Behavior that handles playing a bonus sound effect
class BonusNoiseBehavior extends Component {
@override
Future<void> onLoad() async {
await add(
FlameBlocListener<GameBloc, GameState>(
listenWhen: (previous, current) {
return previous.bonusHistory.length != current.bonusHistory.length;
},
onNewState: (state) {
final bonus = state.bonusHistory.last;
final audioPlayer = readProvider<PinballPlayer>();
switch (bonus) {
case GameBonus.googleWord:
audioPlayer.play(PinballAudio.google);
break;
case GameBonus.sparkyTurboCharge:
audioPlayer.play(PinballAudio.sparky);
break;
case GameBonus.dinoChomp:
// TODO(erickzanardo): Add sound
break;
case GameBonus.androidSpaceship:
// TODO(erickzanardo): Add sound
break;
case GameBonus.dashNest:
// TODO(erickzanardo): Add sound
break;
}
},
),
);
}
}

@ -1,7 +1,6 @@
export 'android_acres/android_acres.dart';
export 'backbox/backbox.dart';
export 'bottom_group.dart';
export 'controlled_ball.dart';
export 'controlled_flipper.dart';
export 'controlled_plunger.dart';
export 'dino_desert/dino_desert.dart';
@ -12,4 +11,4 @@ export 'google_word/google_word.dart';
export 'launcher.dart';
export 'multiballs/multiballs.dart';
export 'multipliers/multipliers.dart';
export 'sparky_scorch.dart';
export 'sparky_scorch/sparky_scorch.dart';

@ -1,66 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart';
/// {@template controlled_ball}
/// A [Ball] with a [BallController] attached.
///
/// When a [Ball] is lost, if there aren't more [Ball]s in play and the game is
/// not over, a new [Ball] will be spawned.
/// {@endtemplate}
class ControlledBall extends Ball with Controls<BallController> {
/// A [Ball] that launches from the [Plunger].
ControlledBall.launch({
required CharacterTheme characterTheme,
}) : super(assetPath: characterTheme.ball.keyName) {
controller = BallController(this);
layer = Layer.launcher;
zIndex = ZIndexes.ballOnLaunchRamp;
}
/// {@macro controlled_ball}
ControlledBall.bonus({
required CharacterTheme characterTheme,
}) : super(assetPath: characterTheme.ball.keyName) {
controller = BallController(this);
zIndex = ZIndexes.ballOnBoard;
}
/// [Ball] used in [DebugPinballGame].
ControlledBall.debug() : super() {
controller = BallController(this);
zIndex = ZIndexes.ballOnBoard;
}
}
/// {@template ball_controller}
/// Controller attached to a [Ball] that handles its game related logic.
/// {@endtemplate}
class BallController extends ComponentController<Ball>
with FlameBlocReader<GameBloc, GameState> {
/// {@macro ball_controller}
BallController(Ball ball) : super(ball);
/// Stops the [Ball] inside of the [SparkyComputer] while the turbo charge
/// sequence runs, then boosts the ball out of the computer.
Future<void> turboCharge() async {
bloc.add(const SparkyTurboChargeActivated());
component.stop();
// TODO(alestiago): Refactor this hard coded duration once the following is
// merged:
// https://github.com/flame-engine/flame/pull/1564
await Future<void>.delayed(
const Duration(milliseconds: 2583),
);
component.resume();
await component.add(
BallTurboChargingBehavior(impulse: Vector2(40, 110)),
);
}
}

@ -41,10 +41,13 @@ class FlutterForestBonusBehavior extends Component
if (signpost.bloc.isFullyProgressed()) {
bloc.add(const BonusActivated(GameBonus.dashNest));
final characterTheme = readProvider<CharacterTheme>();
canvas.add(
ControlledBall.bonus(
characterTheme: readProvider<CharacterTheme>(),
)..initialPosition = Vector2(29.2, -24.5),
Ball(
assetPath: characterTheme.ball.keyName,
)
..initialPosition = Vector2(29.2, -24.5)
..zIndex = ZIndexes.ballOnBoard,
);
animatronic.playing = true;
signpost.bloc.onProgressed();

@ -1,7 +1,6 @@
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
@ -22,7 +21,6 @@ class GoogleWordBonusBehavior extends Component
.every((letter) => letter.bloc.state == GoogleLetterState.lit);
if (achievedBonus) {
readProvider<PinballPlayer>().play(PinballAudio.google);
bloc.add(const BonusActivated(GameBonus.googleWord));
for (final letter in googleLetters) {
letter.bloc.onReset();

@ -0,0 +1 @@
export 'sparky_computer_bonus_behavior.dart';

@ -0,0 +1,28 @@
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// Adds a [GameBonus.sparkyTurboCharge] when a [Ball] enters the
/// [SparkyComputer].
class SparkyComputerBonusBehavior extends Component
with ParentIsA<SparkyScorch>, FlameBlocReader<GameBloc, GameState> {
@override
void onMount() {
super.onMount();
final sparkyComputer = parent.firstChild<SparkyComputer>()!;
final animatronic = parent.firstChild<SparkyAnimatronic>()!;
// TODO(alestiago): Refactor subscription management once the following is
// merged:
// https://github.com/flame-engine/flame/pull/1538
sparkyComputer.bloc.stream.listen((state) async {
final listenWhen = state == SparkyComputerState.withBall;
if (!listenWhen) return;
bloc.add(const BonusActivated(GameBonus.sparkyTurboCharge));
animatronic.playing = true;
});
}
}

@ -1,9 +1,9 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/game/components/components.dart';
import 'package:pinball/game/components/sparky_scorch/behaviors/behaviors.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template sparky_scorch}
@ -33,51 +33,20 @@ class SparkyScorch extends Component {
BumperNoiseBehavior(),
],
)..initialPosition = Vector2(-3.3, -52.55),
SparkyComputerSensor()..initialPosition = Vector2(-13.2, -49.9),
SparkyAnimatronic()..position = Vector2(-14, -58.2),
SparkyComputer(),
],
);
}
/// {@template sparky_computer_sensor}
/// Small sensor body used to detect when a ball has entered the
/// [SparkyComputer].
/// {@endtemplate}
class SparkyComputerSensor extends BodyComponent
with InitialPosition, ContactCallbacks {
/// {@macro sparky_computer_sensor}
SparkyComputerSensor()
: super(
renderBody: false,
children: [
ScoringContactBehavior(points: Points.twentyThousand),
SparkyComputer(
children: [
ScoringContactBehavior(points: Points.twoHundredThousand)
..applyTo(['turbo_charge_sensor']),
],
),
SparkyComputerBonusBehavior(),
],
);
@override
Body createBody() {
final shape = PolygonShape()
..setAsBox(
1,
0.1,
Vector2.zero(),
-0.18,
);
final fixtureDef = FixtureDef(shape, isSensor: true);
final bodyDef = BodyDef(
position: initialPosition,
userData: this,
);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! ControlledBall) return;
other.controller.turboCharge();
gameRef.firstChild<SparkyAnimatronic>()?.playing = true;
}
/// Creates [SparkyScorch] without any children.
///
/// This can be used for testing [SparkyScorch]'s behaviors in isolation.
@visibleForTesting
SparkyScorch.test();
}

@ -24,7 +24,8 @@ class PinballGame extends PinballForge2DGame
required GameBloc gameBloc,
required AppLocalizations l10n,
required PinballPlayer player,
}) : _gameBloc = gameBloc,
}) : focusNode = FocusNode(),
_gameBloc = gameBloc,
_player = player,
_characterTheme = characterTheme,
_l10n = l10n,
@ -40,6 +41,8 @@ class PinballGame extends PinballForge2DGame
@override
Color backgroundColor() => Colors.transparent;
final FocusNode focusNode;
final CharacterTheme _characterTheme;
final PinballPlayer _player;
@ -64,6 +67,7 @@ class PinballGame extends PinballForge2DGame
FlameProvider<AppLocalizations>.value(_l10n),
],
children: [
BonusNoiseBehavior(),
GameBlocStatusListener(),
BallSpawningBehavior(),
CameraFocusingBehavior(),
@ -187,8 +191,7 @@ class DebugPinballGame extends PinballGame with FPSCounter, PanDetector {
if (info.raw.kind == PointerDeviceKind.mouse) {
final canvas = descendants().whereType<ZCanvasComponent>().single;
final ball = ControlledBall.debug()
..initialPosition = info.eventPosition.game;
final ball = Ball()..initialPosition = info.eventPosition.game;
canvas.add(ball);
}
}
@ -215,7 +218,7 @@ class DebugPinballGame extends PinballGame with FPSCounter, PanDetector {
void _turboChargeBall(Vector2 line) {
final canvas = descendants().whereType<ZCanvasComponent>().single;
final ball = ControlledBall.debug()..initialPosition = lineStart!;
final ball = Ball()..initialPosition = lineStart!;
final impulse = line * -1 * 10;
ball.add(BallTurboChargingBehavior(impulse: impulse));
canvas.add(ball);
@ -261,7 +264,7 @@ class _DebugInformation extends Component with HasGameRef<DebugPinballGame> {
void render(Canvas canvas) {
final debugText = [
'FPS: ${gameRef.fps().toStringAsFixed(1)}',
'BALLS: ${gameRef.descendants().whereType<ControlledBall>().length}',
'BALLS: ${gameRef.descendants().whereType<Ball>().length}',
].join(' | ');
final height = _debugTextPaint.measureTextHeight(debugText);

@ -7,7 +7,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/assets_manager/assets_manager.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/gen/gen.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/more_information/more_information.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball/start_game/start_game.dart';
import 'package:pinball_audio/pinball_audio.dart';
@ -21,15 +23,9 @@ class PinballGamePage extends StatelessWidget {
final bool isDebugMode;
static Route route({
bool isDebugMode = kDebugMode,
}) {
static Route route({bool isDebugMode = kDebugMode}) {
return MaterialPageRoute<void>(
builder: (context) {
return PinballGamePage(
isDebugMode: isDebugMode,
);
},
builder: (_) => PinballGamePage(isDebugMode: isDebugMode),
);
}
@ -39,41 +35,33 @@ class PinballGamePage extends StatelessWidget {
context.read<CharacterThemeCubit>().state.characterTheme;
final player = context.read<PinballPlayer>();
final leaderboardRepository = context.read<LeaderboardRepository>();
final gameBloc = context.read<GameBloc>();
final game = isDebugMode
? DebugPinballGame(
characterTheme: characterTheme,
player: player,
leaderboardRepository: leaderboardRepository,
l10n: context.l10n,
gameBloc: gameBloc,
)
: PinballGame(
characterTheme: characterTheme,
player: player,
leaderboardRepository: leaderboardRepository,
l10n: context.l10n,
gameBloc: gameBloc,
);
final loadables = [
...game.preLoadAssets(),
...player.load(),
...BonusAnimation.loadAssets(),
...SelectedCharacter.loadAssets(),
];
return BlocProvider(
create: (_) => GameBloc(),
child: Builder(
builder: (context) {
final gameBloc = context.read<GameBloc>();
final game = isDebugMode
? DebugPinballGame(
characterTheme: characterTheme,
player: player,
leaderboardRepository: leaderboardRepository,
l10n: context.l10n,
gameBloc: gameBloc,
)
: PinballGame(
characterTheme: characterTheme,
player: player,
leaderboardRepository: leaderboardRepository,
l10n: context.l10n,
gameBloc: gameBloc,
);
final loadables = [
...game.preLoadAssets(),
...player.load(),
...BonusAnimation.loadAssets(),
...SelectedCharacter.loadAssets(),
];
return BlocProvider(
create: (_) => AssetsManagerCubit(loadables)..load(),
child: PinballGameView(game: game),
);
},
),
create: (_) => AssetsManagerCubit(loadables)..load(),
child: PinballGameView(game: game),
);
}
}
@ -118,22 +106,31 @@ class PinballGameLoadedView extends StatelessWidget {
child: Stack(
children: [
Positioned.fill(
child: GameWidget<PinballGame>(
game: game,
initialActiveOverlays: const [PinballGame.playButtonOverlay],
overlayBuilderMap: {
PinballGame.playButtonOverlay: (context, game) {
return const Positioned(
bottom: 20,
right: 0,
left: 0,
child: PlayButtonOverlay(),
);
},
child: MouseRegion(
onHover: (_) {
if (!game.focusNode.hasFocus) {
game.focusNode.requestFocus();
}
},
child: GameWidget<PinballGame>(
game: game,
focusNode: game.focusNode,
initialActiveOverlays: const [PinballGame.playButtonOverlay],
overlayBuilderMap: {
PinballGame.playButtonOverlay: (context, game) {
return const Positioned(
bottom: 20,
right: 0,
left: 0,
child: PlayButtonOverlay(),
);
},
},
),
),
),
const _PositionedGameHud(),
const _PositionedInfoIcon(),
],
),
);
@ -151,6 +148,7 @@ class _PositionedGameHud extends StatelessWidget {
final isGameOver = context.select(
(GameBloc bloc) => bloc.state.status.isGameOver,
);
final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16;
final screenWidth = MediaQuery.of(context).size.width;
final leftMargin = (screenWidth / 2) - (gameWidgetWidth / 1.8);
@ -166,3 +164,27 @@ class _PositionedGameHud extends StatelessWidget {
);
}
}
class _PositionedInfoIcon extends StatelessWidget {
const _PositionedInfoIcon({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned(
top: 0,
left: 0,
child: BlocBuilder<GameBloc, GameState>(
builder: (context, state) {
return Visibility(
visible: state.status.isGameOver,
child: IconButton(
iconSize: 50,
icon: Assets.images.linkBox.infoIcon.image(),
onPressed: () => showMoreInformationDialog(context),
),
);
},
),
);
}
}

@ -104,22 +104,6 @@
"@toSubmit": {
"description": "Ending text displayed on initials input screen informational text span"
},
"footerMadeWithText": "Made with ",
"@footerMadeWithText": {
"description": "Text shown on the footer which mentions technologies used to build the app."
},
"footerFlutterLinkText": "Flutter",
"@footerFlutterLinkText": {
"description": "Text on the link shown on the footer which navigates to the Flutter page"
},
"footerFirebaseLinkText": "Firebase",
"@footerFirebaseLinkText": {
"description": "Text on the link shown on the footer which navigates to the Firebase page"
},
"footerGoogleIOText": "Google I/O",
"@footerGoogleIOText": {
"description": "Text shown on the footer which mentions Google I/O"
},
"linkBoxTitle": "Resources",
"@linkBoxTitle": {
"description": "Text shown on the link box title section."

@ -0,0 +1 @@
export 'more_information_dialog.dart';

@ -0,0 +1,218 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// Inflates [MoreInformationDialog] using [showDialog].
Future<void> showMoreInformationDialog(BuildContext context) {
final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16;
return showDialog<void>(
context: context,
barrierColor: PinballColors.transparent,
barrierDismissible: true,
builder: (_) {
return Center(
child: SizedBox(
height: gameWidgetWidth * 0.87,
width: gameWidgetWidth,
child: const MoreInformationDialog(),
),
);
},
);
}
/// {@template more_information_dialog}
/// Dialog used to show informational links
/// {@endtemplate}
class MoreInformationDialog extends StatelessWidget {
/// {@macro more_information_dialog}
const MoreInformationDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Material(
color: PinballColors.transparent,
child: _LinkBoxDecoration(
child: Column(
children: const [
SizedBox(height: 16),
_LinkBoxHeader(),
Expanded(
child: _LinkBoxBody(),
),
],
),
),
);
}
}
class _LinkBoxHeader extends StatelessWidget {
const _LinkBoxHeader({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final indent = MediaQuery.of(context).size.width / 5;
return Column(
children: [
Text(
l10n.linkBoxTitle,
style: Theme.of(context).textTheme.headline3!.copyWith(
color: PinballColors.blue,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Divider(
color: PinballColors.white,
endIndent: indent,
indent: indent,
thickness: 2,
),
],
);
}
}
class _LinkBoxDecoration extends StatelessWidget {
const _LinkBoxDecoration({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const CrtBackground().copyWith(
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(
color: PinballColors.white,
width: 5,
),
),
child: child,
);
}
}
class _LinkBoxBody extends StatelessWidget {
const _LinkBoxBody({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const _MadeWithFlutterAndFirebase(),
_TextLink(
text: l10n.linkBoxOpenSourceCode,
link: _MoreInformationUrl.openSourceCode,
),
_TextLink(
text: l10n.linkBoxGoogleIOText,
link: _MoreInformationUrl.googleIOEvent,
),
_TextLink(
text: l10n.linkBoxFlutterGames,
link: _MoreInformationUrl.flutterGamesWebsite,
),
_TextLink(
text: l10n.linkBoxHowItsMade,
link: _MoreInformationUrl.howItsMadeArticle,
),
_TextLink(
text: l10n.linkBoxTermsOfService,
link: _MoreInformationUrl.termsOfService,
),
_TextLink(
text: l10n.linkBoxPrivacyPolicy,
link: _MoreInformationUrl.privacyPolicy,
),
],
);
}
}
class _TextLink extends StatelessWidget {
const _TextLink({
Key? key,
required this.text,
required this.link,
}) : super(key: key);
final String text;
final String link;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () => openLink(link),
child: Text(
text,
style: theme.textTheme.headline5!.copyWith(
color: PinballColors.white,
),
overflow: TextOverflow.ellipsis,
),
);
}
}
class _MadeWithFlutterAndFirebase extends StatelessWidget {
const _MadeWithFlutterAndFirebase({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: l10n.linkBoxMadeWithText,
style: theme.textTheme.headline5!.copyWith(color: PinballColors.white),
children: <TextSpan>[
TextSpan(
text: l10n.linkBoxFlutterLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink(_MoreInformationUrl.flutterWebsite),
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
const TextSpan(text: ' & '),
TextSpan(
text: l10n.linkBoxFirebaseLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink(_MoreInformationUrl.firebaseWebsite),
style: theme.textTheme.headline5!.copyWith(
decoration: TextDecoration.underline,
),
),
],
),
);
}
}
abstract class _MoreInformationUrl {
static const flutterWebsite = 'https://flutter.dev';
static const firebaseWebsite = 'https://firebase.google.com';
static const openSourceCode = 'https://github.com/VGVentures/pinball';
static const googleIOEvent = 'https://events.google.com/io/';
static const flutterGamesWebsite = 'http://flutter.dev/games';
static const howItsMadeArticle =
'https://medium.com/flutter/i-o-pinball-powered-by-flutter-and-firebase-d22423f3f5d';
static const termsOfService = 'https://policies.google.com/terms';
static const privacyPolicy = 'https://policies.google.com/privacy';
}

@ -44,31 +44,10 @@ class LeaderboardRepository {
final tenthPositionScore = leaderboard[9].score;
if (entry.score > tenthPositionScore) {
await _saveScore(entry);
await _deleteScoresUnder(tenthPositionScore);
}
}
}
/// Determines if the given [initials] are allowed.
Future<bool> areInitialsAllowed({required String initials}) async {
// Initials can only be three uppercase A-Z letters
final initialsRegex = RegExp(r'^[A-Z]{3}$');
if (!initialsRegex.hasMatch(initials)) {
return false;
}
try {
final document = await _firebaseFirestore
.collection('prohibitedInitials')
.doc('list')
.get();
final prohibitedInitials =
document.get('prohibitedInitials') as List<String>;
return !prohibitedInitials.contains(initials);
} on Exception catch (error, stackTrace) {
throw FetchProhibitedInitialsException(error, stackTrace);
}
}
Future<List<LeaderboardEntryData>> _fetchLeaderboardSortedByScore() async {
try {
final querySnapshot = await _firebaseFirestore
@ -91,23 +70,6 @@ class LeaderboardRepository {
throw AddLeaderboardEntryException(error, stackTrace);
}
}
Future<void> _deleteScoresUnder(int score) async {
try {
final querySnapshot = await _firebaseFirestore
.collection(_leaderboardCollectionName)
.where(_scoreFieldName, isLessThanOrEqualTo: score)
.get();
final documents = querySnapshot.docs;
for (final document in documents) {
await document.reference.delete();
}
} on LeaderboardDeserializationException {
rethrow;
} on Exception catch (error, stackTrace) {
throw DeleteLeaderboardException(error, stackTrace);
}
}
}
extension on List<QueryDocumentSnapshot> {

@ -40,16 +40,6 @@ class FetchLeaderboardException extends LeaderboardException {
: super(error, stackTrace);
}
/// {@template delete_leaderboard_exception}
/// Exception thrown when failure occurs while deleting the leaderboard under
/// the tenth position.
/// {@endtemplate}
class DeleteLeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const DeleteLeaderboardException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template add_leaderboard_entry_exception}
/// Exception thrown when failure occurs while adding entry to leaderboard.
/// {@endtemplate}
@ -58,12 +48,3 @@ class AddLeaderboardEntryException extends LeaderboardException {
const AddLeaderboardEntryException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template fetch_prohibited_initials_exception}
/// Exception thrown when failure occurs while fetching prohibited initials.
/// {@endtemplate}
class FetchProhibitedInitialsException extends LeaderboardException {
/// {@macro fetch_prohibited_initials_exception}
const FetchProhibitedInitialsException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}

@ -21,9 +21,6 @@ class _MockQueryDocumentSnapshot extends Mock
class _MockDocumentReference extends Mock
implements DocumentReference<Map<String, dynamic>> {}
class _MockDocumentSnapshot extends Mock
implements DocumentSnapshot<Map<String, dynamic>> {}
void main() {
group('LeaderboardRepository', () {
late FirebaseFirestore firestore;
@ -245,73 +242,10 @@ void main() {
);
});
test(
'throws DeleteLeaderboardException '
'when deleting scores outside the top 10 fails', () async {
final deleteQuery = _MockQuery();
final deleteQuerySnapshot = _MockQuerySnapshot();
final newScore = LeaderboardEntryData(
playerInitials: 'ABC',
score: 15000,
character: CharacterType.android,
);
final leaderboardScores = [
10000,
9500,
9000,
8500,
8000,
7500,
7000,
6500,
6000,
5500,
5000,
];
final deleteDocumentSnapshots = [5500, 5000].map((score) {
final queryDocumentSnapshot = _MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
when(() => queryDocumentSnapshot.reference)
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(deleteQuery.get).thenAnswer((_) async => deleteQuerySnapshot);
when(() => deleteQuerySnapshot.docs)
.thenReturn(deleteDocumentSnapshots);
final queryDocumentSnapshots = leaderboardScores.map((score) {
final queryDocumentSnapshot = _MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
when(() => queryDocumentSnapshot.reference)
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(
() => collectionReference.where('score', isLessThanOrEqualTo: 5500),
).thenAnswer((_) => deleteQuery);
when(() => documentReference.delete()).thenThrow(Exception('oops'));
when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots);
expect(
() => leaderboardRepository.addLeaderboardEntry(newScore),
throwsA(isA<DeleteLeaderboardException>()),
);
});
test(
'saves the new score when there are more than 10 scores in the '
'leaderboard and the new score is higher than the lowest top 10, and '
'deletes the scores that are not in the top 10 anymore', () async {
final deleteQuery = _MockQuery();
final deleteQuerySnapshot = _MockQuerySnapshot();
'leaderboard and the new score is higher than the lowest top 10',
() async {
final newScore = LeaderboardEntryData(
playerInitials: 'ABC',
score: 15000,
@ -330,21 +264,6 @@ void main() {
5500,
5000,
];
final deleteDocumentSnapshots = [5500, 5000].map((score) {
final queryDocumentSnapshot = _MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
when(() => queryDocumentSnapshot.reference)
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(deleteQuery.get).thenAnswer((_) async => deleteQuerySnapshot);
when(() => deleteQuerySnapshot.docs)
.thenReturn(deleteDocumentSnapshots);
final queryDocumentSnapshots = leaderboardScores.map((score) {
final queryDocumentSnapshot = _MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
@ -357,105 +276,11 @@ void main() {
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(
() => collectionReference.where('score', isLessThanOrEqualTo: 5500),
).thenAnswer((_) => deleteQuery);
when(() => documentReference.delete())
.thenAnswer((_) async => Future.value());
when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots);
await leaderboardRepository.addLeaderboardEntry(newScore);
verify(() => collectionReference.add(newScore.toJson())).called(1);
verify(() => documentReference.delete()).called(2);
});
});
group('areInitialsAllowed', () {
late LeaderboardRepository leaderboardRepository;
late CollectionReference<Map<String, dynamic>> collectionReference;
late DocumentReference<Map<String, dynamic>> documentReference;
late DocumentSnapshot<Map<String, dynamic>> documentSnapshot;
setUp(() async {
collectionReference = _MockCollectionReference();
documentReference = _MockDocumentReference();
documentSnapshot = _MockDocumentSnapshot();
leaderboardRepository = LeaderboardRepository(firestore);
when(() => firestore.collection('prohibitedInitials'))
.thenReturn(collectionReference);
when(() => collectionReference.doc('list'))
.thenReturn(documentReference);
when(() => documentReference.get())
.thenAnswer((_) async => documentSnapshot);
when<dynamic>(() => documentSnapshot.get('prohibitedInitials'))
.thenReturn(['BAD']);
});
test('returns true if initials are three letters and allowed', () async {
final isUsernameAllowedResponse =
await leaderboardRepository.areInitialsAllowed(
initials: 'ABC',
);
expect(
isUsernameAllowedResponse,
isTrue,
);
});
test(
'returns false if initials are shorter than 3 characters',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'AB');
expect(areInitialsAllowedResponse, isFalse);
},
);
test(
'returns false if initials are longer than 3 characters',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'ABCD');
expect(areInitialsAllowedResponse, isFalse);
},
);
test(
'returns false if initials contain a lowercase letter',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'AbC');
expect(areInitialsAllowedResponse, isFalse);
},
);
test(
'returns false if initials contain a special character',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'A@C');
expect(areInitialsAllowedResponse, isFalse);
},
);
test('returns false if initials are forbidden', () async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'BAD');
expect(areInitialsAllowedResponse, isFalse);
});
test(
'throws FetchProhibitedInitialsException when Exception occurs '
'when trying to retrieve information from firestore',
() async {
when(() => firestore.collection('prohibitedInitials'))
.thenThrow(Exception('oops'));
expect(
() => leaderboardRepository.areInitialsAllowed(initials: 'ABC'),
throwsA(isA<FetchProhibitedInitialsException>()),
);
},
);
});
});
}

@ -14,13 +14,13 @@ class $AssetsMusicGen {
class $AssetsSfxGen {
const $AssetsSfxGen();
String get afterLaunch => 'assets/sfx/after_launch.mp3';
String get bumperA => 'assets/sfx/bumper_a.mp3';
String get bumperB => 'assets/sfx/bumper_b.mp3';
String get gameOverVoiceOver => 'assets/sfx/game_over_voice_over.mp3';
String get google => 'assets/sfx/google.mp3';
String get ioPinballVoiceOver => 'assets/sfx/io_pinball_voice_over.mp3';
String get launcher => 'assets/sfx/launcher.mp3';
String get sparky => 'assets/sfx/sparky.mp3';
}
class Assets {

@ -25,6 +25,9 @@ enum PinballAudio {
/// Launcher
launcher,
/// Sparky
sparky,
}
/// Defines the contract of the creation of an [AudioPool].
@ -161,6 +164,11 @@ class PinballPlayer {
playSingleAudio: _playSingleAudio,
path: Assets.sfx.google,
),
PinballAudio.sparky: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
path: Assets.sfx.sparky,
),
PinballAudio.launcher: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,

@ -141,6 +141,10 @@ void main() {
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/google.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/sparky.mp3'),
).called(1);
verify(
() => preCacheSingleAudio.onCall(
'packages/pinball_audio/assets/sfx/io_pinball_voice_over.mp3',
@ -211,7 +215,7 @@ void main() {
});
});
group('googleBonus', () {
group('google', () {
test('plays the correct file', () async {
await Future.wait(player.load());
player.play(PinballAudio.google);
@ -223,6 +227,18 @@ void main() {
});
});
group('sparky', () {
test('plays the correct file', () async {
await Future.wait(player.load());
player.play(PinballAudio.sparky);
verify(
() => playSingleAudio
.onCall('packages/pinball_audio/${Assets.sfx.sparky}'),
).called(1);
});
});
group('launcher', () {
test('plays the correct file', () async {
await Future.wait(player.load());

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -3,8 +3,6 @@
/// FlutterGen
/// *****************************************************
// ignore_for_file: directives_ordering,unnecessary_import
import 'package:flutter/widgets.dart';
class $AssetsImagesGen {
@ -14,14 +12,13 @@ class $AssetsImagesGen {
$AssetsImagesBackboxGen get backbox => const $AssetsImagesBackboxGen();
$AssetsImagesBallGen get ball => const $AssetsImagesBallGen();
$AssetsImagesBaseboardGen get baseboard => const $AssetsImagesBaseboardGen();
/// File path: assets/images/board-background.png
AssetGenImage get boardBackground =>
const AssetGenImage('assets/images/board-background.png');
$AssetsImagesBoundaryGen get boundary => const $AssetsImagesBoundaryGen();
$AssetsImagesDashGen get dash => const $AssetsImagesDashGen();
$AssetsImagesDinoGen get dino => const $AssetsImagesDinoGen();
AssetGenImage get errorBackground =>
const AssetGenImage('assets/images/error_background.png');
$AssetsImagesFlapperGen get flapper => const $AssetsImagesFlapperGen();
$AssetsImagesFlipperGen get flipper => const $AssetsImagesFlipperGen();
$AssetsImagesGoogleWordGen get googleWord =>
@ -54,11 +51,8 @@ class $AssetsImagesAndroidGen {
class $AssetsImagesBackboxGen {
const $AssetsImagesBackboxGen();
/// File path: assets/images/backbox/display-divider.png
AssetGenImage get displayDivider =>
const AssetGenImage('assets/images/backbox/display-divider.png');
/// File path: assets/images/backbox/marquee.png
AssetGenImage get marquee =>
const AssetGenImage('assets/images/backbox/marquee.png');
}
@ -66,10 +60,6 @@ class $AssetsImagesBackboxGen {
class $AssetsImagesBallGen {
const $AssetsImagesBallGen();
/// File path: assets/images/ball/ball.png
AssetGenImage get ball => const AssetGenImage('assets/images/ball/ball.png');
/// File path: assets/images/ball/flame_effect.png
AssetGenImage get flameEffect =>
const AssetGenImage('assets/images/ball/flame_effect.png');
}
@ -77,11 +67,8 @@ class $AssetsImagesBallGen {
class $AssetsImagesBaseboardGen {
const $AssetsImagesBaseboardGen();
/// File path: assets/images/baseboard/left.png
AssetGenImage get left =>
const AssetGenImage('assets/images/baseboard/left.png');
/// File path: assets/images/baseboard/right.png
AssetGenImage get right =>
const AssetGenImage('assets/images/baseboard/right.png');
}
@ -89,15 +76,10 @@ class $AssetsImagesBaseboardGen {
class $AssetsImagesBoundaryGen {
const $AssetsImagesBoundaryGen();
/// File path: assets/images/boundary/bottom.png
AssetGenImage get bottom =>
const AssetGenImage('assets/images/boundary/bottom.png');
/// File path: assets/images/boundary/outer-bottom.png
AssetGenImage get outerBottom =>
const AssetGenImage('assets/images/boundary/outer-bottom.png');
/// File path: assets/images/boundary/outer.png
AssetGenImage get outer =>
const AssetGenImage('assets/images/boundary/outer.png');
}
@ -105,10 +87,8 @@ class $AssetsImagesBoundaryGen {
class $AssetsImagesDashGen {
const $AssetsImagesDashGen();
/// File path: assets/images/dash/animatronic.png
AssetGenImage get animatronic =>
const AssetGenImage('assets/images/dash/animatronic.png');
$AssetsImagesDashBumperGen get bumper => const $AssetsImagesDashBumperGen();
}
@ -117,16 +97,10 @@ class $AssetsImagesDinoGen {
$AssetsImagesDinoAnimatronicGen get animatronic =>
const $AssetsImagesDinoAnimatronicGen();
/// File path: assets/images/dino/bottom-wall.png
AssetGenImage get bottomWall =>
const AssetGenImage('assets/images/dino/bottom-wall.png');
/// File path: assets/images/dino/top-wall-tunnel.png
AssetGenImage get topWallTunnel =>
const AssetGenImage('assets/images/dino/top-wall-tunnel.png');
/// File path: assets/images/dino/top-wall.png
AssetGenImage get topWall =>
const AssetGenImage('assets/images/dino/top-wall.png');
}
@ -134,15 +108,10 @@ class $AssetsImagesDinoGen {
class $AssetsImagesFlapperGen {
const $AssetsImagesFlapperGen();
/// File path: assets/images/flapper/back-support.png
AssetGenImage get backSupport =>
const AssetGenImage('assets/images/flapper/back-support.png');
/// File path: assets/images/flapper/flap.png
AssetGenImage get flap =>
const AssetGenImage('assets/images/flapper/flap.png');
/// File path: assets/images/flapper/front-support.png
AssetGenImage get frontSupport =>
const AssetGenImage('assets/images/flapper/front-support.png');
}
@ -150,11 +119,8 @@ class $AssetsImagesFlapperGen {
class $AssetsImagesFlipperGen {
const $AssetsImagesFlipperGen();
/// File path: assets/images/flipper/left.png
AssetGenImage get left =>
const AssetGenImage('assets/images/flipper/left.png');
/// File path: assets/images/flipper/right.png
AssetGenImage get right =>
const AssetGenImage('assets/images/flipper/right.png');
}
@ -186,15 +152,10 @@ class $AssetsImagesKickerGen {
class $AssetsImagesLaunchRampGen {
const $AssetsImagesLaunchRampGen();
/// File path: assets/images/launch_ramp/background-railing.png
AssetGenImage get backgroundRailing =>
const AssetGenImage('assets/images/launch_ramp/background-railing.png');
/// File path: assets/images/launch_ramp/foreground-railing.png
AssetGenImage get foregroundRailing =>
const AssetGenImage('assets/images/launch_ramp/foreground-railing.png');
/// File path: assets/images/launch_ramp/ramp.png
AssetGenImage get ramp =>
const AssetGenImage('assets/images/launch_ramp/ramp.png');
}
@ -202,11 +163,8 @@ class $AssetsImagesLaunchRampGen {
class $AssetsImagesMultiballGen {
const $AssetsImagesMultiballGen();
/// File path: assets/images/multiball/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiball/dimmed.png');
/// File path: assets/images/multiball/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiball/lit.png');
}
@ -224,11 +182,8 @@ class $AssetsImagesMultiplierGen {
class $AssetsImagesPlungerGen {
const $AssetsImagesPlungerGen();
/// File path: assets/images/plunger/plunger.png
AssetGenImage get plunger =>
const AssetGenImage('assets/images/plunger/plunger.png');
/// File path: assets/images/plunger/rocket.png
AssetGenImage get rocket =>
const AssetGenImage('assets/images/plunger/rocket.png');
}
@ -236,19 +191,12 @@ class $AssetsImagesPlungerGen {
class $AssetsImagesScoreGen {
const $AssetsImagesScoreGen();
/// File path: assets/images/score/five-thousand.png
AssetGenImage get fiveThousand =>
const AssetGenImage('assets/images/score/five-thousand.png');
/// File path: assets/images/score/one-million.png
AssetGenImage get oneMillion =>
const AssetGenImage('assets/images/score/one-million.png');
/// File path: assets/images/score/twenty-thousand.png
AssetGenImage get twentyThousand =>
const AssetGenImage('assets/images/score/twenty-thousand.png');
/// File path: assets/images/score/two-hundred-thousand.png
AssetGenImage get twoHundredThousand =>
const AssetGenImage('assets/images/score/two-hundred-thousand.png');
}
@ -256,19 +204,12 @@ class $AssetsImagesScoreGen {
class $AssetsImagesSignpostGen {
const $AssetsImagesSignpostGen();
/// File path: assets/images/signpost/active1.png
AssetGenImage get active1 =>
const AssetGenImage('assets/images/signpost/active1.png');
/// File path: assets/images/signpost/active2.png
AssetGenImage get active2 =>
const AssetGenImage('assets/images/signpost/active2.png');
/// File path: assets/images/signpost/active3.png
AssetGenImage get active3 =>
const AssetGenImage('assets/images/signpost/active3.png');
/// File path: assets/images/signpost/inactive.png
AssetGenImage get inactive =>
const AssetGenImage('assets/images/signpost/inactive.png');
}
@ -276,19 +217,12 @@ class $AssetsImagesSignpostGen {
class $AssetsImagesSkillShotGen {
const $AssetsImagesSkillShotGen();
/// File path: assets/images/skill_shot/decal.png
AssetGenImage get decal =>
const AssetGenImage('assets/images/skill_shot/decal.png');
/// File path: assets/images/skill_shot/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/skill_shot/dimmed.png');
/// File path: assets/images/skill_shot/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/skill_shot/lit.png');
/// File path: assets/images/skill_shot/pin.png
AssetGenImage get pin =>
const AssetGenImage('assets/images/skill_shot/pin.png');
}
@ -296,11 +230,8 @@ class $AssetsImagesSkillShotGen {
class $AssetsImagesSlingshotGen {
const $AssetsImagesSlingshotGen();
/// File path: assets/images/slingshot/lower.png
AssetGenImage get lower =>
const AssetGenImage('assets/images/slingshot/lower.png');
/// File path: assets/images/slingshot/upper.png
AssetGenImage get upper =>
const AssetGenImage('assets/images/slingshot/upper.png');
}
@ -308,10 +239,8 @@ class $AssetsImagesSlingshotGen {
class $AssetsImagesSparkyGen {
const $AssetsImagesSparkyGen();
/// File path: assets/images/sparky/animatronic.png
AssetGenImage get animatronic =>
const AssetGenImage('assets/images/sparky/animatronic.png');
$AssetsImagesSparkyBumperGen get bumper =>
const $AssetsImagesSparkyBumperGen();
$AssetsImagesSparkyComputerGen get computer =>
@ -332,11 +261,8 @@ class $AssetsImagesAndroidBumperGen {
class $AssetsImagesAndroidRailGen {
const $AssetsImagesAndroidRailGen();
/// File path: assets/images/android/rail/exit.png
AssetGenImage get exit =>
const AssetGenImage('assets/images/android/rail/exit.png');
/// File path: assets/images/android/rail/main.png
AssetGenImage get main =>
const AssetGenImage('assets/images/android/rail/main.png');
}
@ -346,20 +272,12 @@ class $AssetsImagesAndroidRampGen {
$AssetsImagesAndroidRampArrowGen get arrow =>
const $AssetsImagesAndroidRampArrowGen();
/// File path: assets/images/android/ramp/board-opening.png
AssetGenImage get boardOpening =>
const AssetGenImage('assets/images/android/ramp/board-opening.png');
/// File path: assets/images/android/ramp/main.png
AssetGenImage get main =>
const AssetGenImage('assets/images/android/ramp/main.png');
/// File path: assets/images/android/ramp/railing-background.png
AssetGenImage get railingBackground =>
const AssetGenImage('assets/images/android/ramp/railing-background.png');
/// File path: assets/images/android/ramp/railing-foreground.png
AssetGenImage get railingForeground =>
const AssetGenImage('assets/images/android/ramp/railing-foreground.png');
}
@ -367,15 +285,10 @@ class $AssetsImagesAndroidRampGen {
class $AssetsImagesAndroidSpaceshipGen {
const $AssetsImagesAndroidSpaceshipGen();
/// File path: assets/images/android/spaceship/animatronic.png
AssetGenImage get animatronic =>
const AssetGenImage('assets/images/android/spaceship/animatronic.png');
/// File path: assets/images/android/spaceship/light-beam.png
AssetGenImage get lightBeam =>
const AssetGenImage('assets/images/android/spaceship/light-beam.png');
/// File path: assets/images/android/spaceship/saucer.png
AssetGenImage get saucer =>
const AssetGenImage('assets/images/android/spaceship/saucer.png');
}
@ -392,11 +305,8 @@ class $AssetsImagesDashBumperGen {
class $AssetsImagesDinoAnimatronicGen {
const $AssetsImagesDinoAnimatronicGen();
/// File path: assets/images/dino/animatronic/head.png
AssetGenImage get head =>
const AssetGenImage('assets/images/dino/animatronic/head.png');
/// File path: assets/images/dino/animatronic/mouth.png
AssetGenImage get mouth =>
const AssetGenImage('assets/images/dino/animatronic/mouth.png');
}
@ -404,11 +314,8 @@ class $AssetsImagesDinoAnimatronicGen {
class $AssetsImagesGoogleWordLetter1Gen {
const $AssetsImagesGoogleWordLetter1Gen();
/// File path: assets/images/google_word/letter1/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/google_word/letter1/dimmed.png');
/// File path: assets/images/google_word/letter1/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/google_word/letter1/lit.png');
}
@ -416,11 +323,8 @@ class $AssetsImagesGoogleWordLetter1Gen {
class $AssetsImagesGoogleWordLetter2Gen {
const $AssetsImagesGoogleWordLetter2Gen();
/// File path: assets/images/google_word/letter2/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/google_word/letter2/dimmed.png');
/// File path: assets/images/google_word/letter2/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/google_word/letter2/lit.png');
}
@ -428,11 +332,8 @@ class $AssetsImagesGoogleWordLetter2Gen {
class $AssetsImagesGoogleWordLetter3Gen {
const $AssetsImagesGoogleWordLetter3Gen();
/// File path: assets/images/google_word/letter3/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/google_word/letter3/dimmed.png');
/// File path: assets/images/google_word/letter3/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/google_word/letter3/lit.png');
}
@ -440,11 +341,8 @@ class $AssetsImagesGoogleWordLetter3Gen {
class $AssetsImagesGoogleWordLetter4Gen {
const $AssetsImagesGoogleWordLetter4Gen();
/// File path: assets/images/google_word/letter4/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/google_word/letter4/dimmed.png');
/// File path: assets/images/google_word/letter4/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/google_word/letter4/lit.png');
}
@ -452,11 +350,8 @@ class $AssetsImagesGoogleWordLetter4Gen {
class $AssetsImagesGoogleWordLetter5Gen {
const $AssetsImagesGoogleWordLetter5Gen();
/// File path: assets/images/google_word/letter5/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/google_word/letter5/dimmed.png');
/// File path: assets/images/google_word/letter5/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/google_word/letter5/lit.png');
}
@ -464,11 +359,8 @@ class $AssetsImagesGoogleWordLetter5Gen {
class $AssetsImagesGoogleWordLetter6Gen {
const $AssetsImagesGoogleWordLetter6Gen();
/// File path: assets/images/google_word/letter6/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/google_word/letter6/dimmed.png');
/// File path: assets/images/google_word/letter6/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/google_word/letter6/lit.png');
}
@ -476,11 +368,8 @@ class $AssetsImagesGoogleWordLetter6Gen {
class $AssetsImagesKickerLeftGen {
const $AssetsImagesKickerLeftGen();
/// File path: assets/images/kicker/left/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/kicker/left/dimmed.png');
/// File path: assets/images/kicker/left/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/kicker/left/lit.png');
}
@ -488,11 +377,8 @@ class $AssetsImagesKickerLeftGen {
class $AssetsImagesKickerRightGen {
const $AssetsImagesKickerRightGen();
/// File path: assets/images/kicker/right/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/kicker/right/dimmed.png');
/// File path: assets/images/kicker/right/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/kicker/right/lit.png');
}
@ -500,11 +386,8 @@ class $AssetsImagesKickerRightGen {
class $AssetsImagesMultiplierX2Gen {
const $AssetsImagesMultiplierX2Gen();
/// File path: assets/images/multiplier/x2/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x2/dimmed.png');
/// File path: assets/images/multiplier/x2/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x2/lit.png');
}
@ -512,11 +395,8 @@ class $AssetsImagesMultiplierX2Gen {
class $AssetsImagesMultiplierX3Gen {
const $AssetsImagesMultiplierX3Gen();
/// File path: assets/images/multiplier/x3/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x3/dimmed.png');
/// File path: assets/images/multiplier/x3/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x3/lit.png');
}
@ -524,11 +404,8 @@ class $AssetsImagesMultiplierX3Gen {
class $AssetsImagesMultiplierX4Gen {
const $AssetsImagesMultiplierX4Gen();
/// File path: assets/images/multiplier/x4/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x4/dimmed.png');
/// File path: assets/images/multiplier/x4/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x4/lit.png');
}
@ -536,11 +413,8 @@ class $AssetsImagesMultiplierX4Gen {
class $AssetsImagesMultiplierX5Gen {
const $AssetsImagesMultiplierX5Gen();
/// File path: assets/images/multiplier/x5/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x5/dimmed.png');
/// File path: assets/images/multiplier/x5/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x5/lit.png');
}
@ -548,11 +422,8 @@ class $AssetsImagesMultiplierX5Gen {
class $AssetsImagesMultiplierX6Gen {
const $AssetsImagesMultiplierX6Gen();
/// File path: assets/images/multiplier/x6/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x6/dimmed.png');
/// File path: assets/images/multiplier/x6/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x6/lit.png');
}
@ -568,15 +439,10 @@ class $AssetsImagesSparkyBumperGen {
class $AssetsImagesSparkyComputerGen {
const $AssetsImagesSparkyComputerGen();
/// File path: assets/images/sparky/computer/base.png
AssetGenImage get base =>
const AssetGenImage('assets/images/sparky/computer/base.png');
/// File path: assets/images/sparky/computer/glow.png
AssetGenImage get glow =>
const AssetGenImage('assets/images/sparky/computer/glow.png');
/// File path: assets/images/sparky/computer/top.png
AssetGenImage get top =>
const AssetGenImage('assets/images/sparky/computer/top.png');
}
@ -584,11 +450,8 @@ class $AssetsImagesSparkyComputerGen {
class $AssetsImagesAndroidBumperAGen {
const $AssetsImagesAndroidBumperAGen();
/// File path: assets/images/android/bumper/a/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/android/bumper/a/dimmed.png');
/// File path: assets/images/android/bumper/a/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/android/bumper/a/lit.png');
}
@ -596,11 +459,8 @@ class $AssetsImagesAndroidBumperAGen {
class $AssetsImagesAndroidBumperBGen {
const $AssetsImagesAndroidBumperBGen();
/// File path: assets/images/android/bumper/b/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/android/bumper/b/dimmed.png');
/// File path: assets/images/android/bumper/b/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/android/bumper/b/lit.png');
}
@ -608,11 +468,8 @@ class $AssetsImagesAndroidBumperBGen {
class $AssetsImagesAndroidBumperCowGen {
const $AssetsImagesAndroidBumperCowGen();
/// File path: assets/images/android/bumper/cow/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/android/bumper/cow/dimmed.png');
/// File path: assets/images/android/bumper/cow/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/android/bumper/cow/lit.png');
}
@ -620,27 +477,16 @@ class $AssetsImagesAndroidBumperCowGen {
class $AssetsImagesAndroidRampArrowGen {
const $AssetsImagesAndroidRampArrowGen();
/// File path: assets/images/android/ramp/arrow/active1.png
AssetGenImage get active1 =>
const AssetGenImage('assets/images/android/ramp/arrow/active1.png');
/// File path: assets/images/android/ramp/arrow/active2.png
AssetGenImage get active2 =>
const AssetGenImage('assets/images/android/ramp/arrow/active2.png');
/// File path: assets/images/android/ramp/arrow/active3.png
AssetGenImage get active3 =>
const AssetGenImage('assets/images/android/ramp/arrow/active3.png');
/// File path: assets/images/android/ramp/arrow/active4.png
AssetGenImage get active4 =>
const AssetGenImage('assets/images/android/ramp/arrow/active4.png');
/// File path: assets/images/android/ramp/arrow/active5.png
AssetGenImage get active5 =>
const AssetGenImage('assets/images/android/ramp/arrow/active5.png');
/// File path: assets/images/android/ramp/arrow/inactive.png
AssetGenImage get inactive =>
const AssetGenImage('assets/images/android/ramp/arrow/inactive.png');
}
@ -648,11 +494,8 @@ class $AssetsImagesAndroidRampArrowGen {
class $AssetsImagesDashBumperAGen {
const $AssetsImagesDashBumperAGen();
/// File path: assets/images/dash/bumper/a/active.png
AssetGenImage get active =>
const AssetGenImage('assets/images/dash/bumper/a/active.png');
/// File path: assets/images/dash/bumper/a/inactive.png
AssetGenImage get inactive =>
const AssetGenImage('assets/images/dash/bumper/a/inactive.png');
}
@ -660,11 +503,8 @@ class $AssetsImagesDashBumperAGen {
class $AssetsImagesDashBumperBGen {
const $AssetsImagesDashBumperBGen();
/// File path: assets/images/dash/bumper/b/active.png
AssetGenImage get active =>
const AssetGenImage('assets/images/dash/bumper/b/active.png');
/// File path: assets/images/dash/bumper/b/inactive.png
AssetGenImage get inactive =>
const AssetGenImage('assets/images/dash/bumper/b/inactive.png');
}
@ -672,11 +512,8 @@ class $AssetsImagesDashBumperBGen {
class $AssetsImagesDashBumperMainGen {
const $AssetsImagesDashBumperMainGen();
/// File path: assets/images/dash/bumper/main/active.png
AssetGenImage get active =>
const AssetGenImage('assets/images/dash/bumper/main/active.png');
/// File path: assets/images/dash/bumper/main/inactive.png
AssetGenImage get inactive =>
const AssetGenImage('assets/images/dash/bumper/main/inactive.png');
}
@ -684,11 +521,8 @@ class $AssetsImagesDashBumperMainGen {
class $AssetsImagesSparkyBumperAGen {
const $AssetsImagesSparkyBumperAGen();
/// File path: assets/images/sparky/bumper/a/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/sparky/bumper/a/dimmed.png');
/// File path: assets/images/sparky/bumper/a/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/sparky/bumper/a/lit.png');
}
@ -696,11 +530,8 @@ class $AssetsImagesSparkyBumperAGen {
class $AssetsImagesSparkyBumperBGen {
const $AssetsImagesSparkyBumperBGen();
/// File path: assets/images/sparky/bumper/b/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/sparky/bumper/b/dimmed.png');
/// File path: assets/images/sparky/bumper/b/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/sparky/bumper/b/lit.png');
}
@ -708,11 +539,8 @@ class $AssetsImagesSparkyBumperBGen {
class $AssetsImagesSparkyBumperCGen {
const $AssetsImagesSparkyBumperCGen();
/// File path: assets/images/sparky/bumper/c/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/sparky/bumper/c/dimmed.png');
/// File path: assets/images/sparky/bumper/c/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/sparky/bumper/c/lit.png');
}

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

@ -61,13 +61,13 @@ class ChromeDino extends BodyComponent
List<FixtureDef> _createFixtureDefs() {
const mouthAngle = -(halfSweepingAngle + 0.28);
final size = Vector2(5.5, 6);
final size = Vector2(6, 6);
final topEdge = PolygonShape()
..setAsBox(
size.x / 2,
0.1,
initialPosition + Vector2(-4.2, -1.4),
initialPosition + Vector2(-4, -1.4),
mouthAngle,
);
final topEdgeFixtureDef = FixtureDef(topEdge, density: 100);
@ -76,7 +76,7 @@ class ChromeDino extends BodyComponent
..setAsBox(
0.1,
size.y / 2,
initialPosition + Vector2(-1.3, 0.5),
initialPosition + Vector2(-1, 0.5),
-halfSweepingAngle,
);
final backEdgeFixtureDef = FixtureDef(backEdge, density: 100);
@ -85,7 +85,7 @@ class ChromeDino extends BodyComponent
..setAsBox(
size.x / 2,
0.1,
initialPosition + Vector2(-3.5, 4.7),
initialPosition + Vector2(-3.3, 4.7),
mouthAngle,
);
final bottomEdgeFixtureDef = FixtureDef(
@ -110,7 +110,7 @@ class ChromeDino extends BodyComponent
..setAsBox(
0.2,
0.2,
initialPosition + Vector2(-3.5, 1.5),
initialPosition + Vector2(-3, 1.5),
0,
);
final insideSensorFixtureDef = FixtureDef(

@ -12,6 +12,7 @@ export 'chrome_dino/chrome_dino.dart';
export 'dash_animatronic.dart';
export 'dash_nest_bumper/dash_nest_bumper.dart';
export 'dino_walls.dart';
export 'error_component.dart';
export 'fire_effect.dart';
export 'flapper/flapper.dart';
export 'flipper.dart';
@ -35,5 +36,5 @@ export 'spaceship_rail.dart';
export 'spaceship_ramp/spaceship_ramp.dart';
export 'sparky_animatronic.dart';
export 'sparky_bumper/sparky_bumper.dart';
export 'sparky_computer.dart';
export 'sparky_computer/sparky_computer.dart';
export 'z_indexes.dart';

@ -0,0 +1,95 @@
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_ui/pinball_ui.dart';
final _boldLabelTextPaint = TextPaint(
style: const TextStyle(
fontSize: 1.8,
color: PinballColors.white,
fontFamily: PinballFonts.pixeloidSans,
fontWeight: FontWeight.w700,
),
);
final _labelTextPaint = TextPaint(
style: const TextStyle(
fontSize: 1.8,
color: PinballColors.white,
fontFamily: PinballFonts.pixeloidSans,
fontWeight: FontWeight.w400,
),
);
/// {@template error_component}
/// A plain visual component used to show errors for the user.
/// {@endtemplate}
class ErrorComponent extends SpriteComponent with HasGameRef {
/// {@macro error_component}
ErrorComponent({required this.label, Vector2? position})
: _textPaint = _labelTextPaint,
super(
position: position,
);
/// {@macro error_component}
ErrorComponent.bold({required this.label, Vector2? position})
: _textPaint = _boldLabelTextPaint,
super(
position: position,
);
/// Text shown on the error message.
final String label;
final TextPaint _textPaint;
List<String> _splitInLines() {
final maxWidth = size.x - 8;
final lines = <String>[];
var currentLine = '';
final words = label.split(' ');
while (words.isNotEmpty) {
final word = words.removeAt(0);
if (_textPaint.measureTextWidth('$currentLine $word') <= maxWidth) {
currentLine = '$currentLine $word'.trim();
} else {
lines.add(currentLine);
currentLine = word;
}
}
lines.add(currentLine);
return lines;
}
@override
Future<void> onLoad() async {
anchor = Anchor.center;
final sprite = await gameRef.loadSprite(
Assets.images.errorBackground.keyName,
);
size = sprite.originalSize / 20;
this.sprite = sprite;
final lines = _splitInLines();
// Calculates vertical offset based on the number of lines of text to be
// displayed. This offset is used to keep the middle of the multi-line text
// at the center of the [ErrorComponent].
final yOffset = ((size.y / 2.2) / lines.length) * 1.5;
for (var i = 0; i < lines.length; i++) {
await add(
TextComponent(
position: Vector2(size.x / 2, yOffset + 2.2 * i),
size: Vector2(size.x - 4, 2.2),
text: lines[i],
textRenderer: _textPaint,
anchor: Anchor.center,
),
);
}
}
}

@ -0,0 +1 @@
export 'sparky_computer_sensor_ball_contact_behavior.dart';

@ -0,0 +1,35 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template sparky_computer_sensor_ball_contact_behavior}
/// When a [Ball] enters the [SparkyComputer] it is stopped for a period of time
/// before a [BallTurboChargingBehavior] is applied to it.
/// {@endtemplate}
class SparkyComputerSensorBallContactBehavior
extends ContactBehavior<SparkyComputer> {
@override
Future<void> beginContact(Object other, Contact contact) async {
super.beginContact(other, contact);
if (other is! Ball) return;
other.stop();
parent.bloc.onBallEntered();
await parent.add(
TimerComponent(
period: 1.5,
removeOnFinish: true,
onTick: () async {
other.resume();
await other.add(
BallTurboChargingBehavior(
impulse: Vector2(40, 110),
),
);
parent.bloc.onBallTurboCharged();
},
),
);
}
}

@ -0,0 +1,17 @@
// ignore_for_file: public_member_api_docs
import 'package:bloc/bloc.dart';
part 'sparky_computer_state.dart';
class SparkyComputerCubit extends Cubit<SparkyComputerState> {
SparkyComputerCubit() : super(SparkyComputerState.withoutBall);
void onBallEntered() {
emit(SparkyComputerState.withBall);
}
void onBallTurboCharged() {
emit(SparkyComputerState.withoutBall);
}
}

@ -0,0 +1,8 @@
// ignore_for_file: public_member_api_docs
part of 'sparky_computer_cubit.dart';
enum SparkyComputerState {
withoutBall,
withBall,
}

@ -2,31 +2,52 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_components/src/components/sparky_computer/behaviors/behaviors.dart';
import 'package:pinball_flame/pinball_flame.dart';
export 'cubit/sparky_computer_cubit.dart';
/// {@template sparky_computer}
/// A computer owned by Sparky.
/// {@endtemplate}
class SparkyComputer extends Component {
class SparkyComputer extends BodyComponent {
/// {@macro sparky_computer}
SparkyComputer()
: super(
SparkyComputer({Iterable<Component>? children})
: bloc = SparkyComputerCubit(),
super(
renderBody: false,
children: [
_ComputerBase(),
SparkyComputerSensorBallContactBehavior()
..applyTo(['turbo_charge_sensor']),
_ComputerBaseSpriteComponent(),
_ComputerTopSpriteComponent(),
_ComputerGlowSpriteComponent(),
...?children,
],
);
}
class _ComputerBase extends BodyComponent with InitialPosition, ZIndex {
_ComputerBase()
: super(
renderBody: false,
children: [_ComputerBaseSpriteComponent()],
) {
zIndex = ZIndexes.computerBase;
/// Creates a [SparkyComputer] without any children.
///
/// This can be used for testing [SparkyComputer]'s behaviors in isolation.
// TODO(alestiago): Refactor injecting bloc once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
@visibleForTesting
SparkyComputer.test({
required this.bloc,
Iterable<Component>? children,
}) : super(children: children);
// TODO(alestiago): Consider refactoring once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
// ignore: public_member_api_docs
final SparkyComputerCubit bloc;
@override
void onRemove() {
bloc.close();
super.onRemove();
}
List<FixtureDef> _createFixtureDefs() {
@ -45,30 +66,44 @@ class _ComputerBase extends BodyComponent with InitialPosition, ZIndex {
topEdge.vertex2,
Vector2(-9.4, -47.1),
);
final turboChargeSensor = PolygonShape()
..setAsBox(
1,
0.1,
Vector2(-13.2, -49.9),
-0.18,
);
return [
FixtureDef(leftEdge),
FixtureDef(topEdge),
FixtureDef(rightEdge),
FixtureDef(
turboChargeSensor,
isSensor: true,
userData: 'turbo_charge_sensor',
),
];
}
@override
Body createBody() {
final bodyDef = BodyDef(position: initialPosition);
final body = world.createBody(bodyDef);
final body = world.createBody(BodyDef());
_createFixtureDefs().forEach(body.createFixture);
return body;
}
}
class _ComputerBaseSpriteComponent extends SpriteComponent with HasGameRef {
class _ComputerBaseSpriteComponent extends SpriteComponent
with HasGameRef, ZIndex {
_ComputerBaseSpriteComponent()
: super(
anchor: Anchor.center,
position: Vector2(-12.44, -48.15),
);
) {
zIndex = ZIndexes.computerBase;
}
@override
Future<void> onLoad() async {

@ -11,7 +11,7 @@ dependencies:
flame: ^1.1.1
flame_forge2d:
git:
url: https://github.com/flame-engine/flame/
url: https://github.com/flame-engine/flame
path: packages/flame_forge2d/
ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
flutter:
@ -23,6 +23,8 @@ dependencies:
path: ../pinball_flame
pinball_theme:
path: ../pinball_theme
pinball_ui:
path: ../pinball_ui
dev_dependencies:
bloc_test: ^9.0.3
@ -33,6 +35,7 @@ dev_dependencies:
very_good_analysis: ^2.4.0
flutter:
uses-material-design: true
generate: true
fonts:
- family: PixeloidSans

@ -8,6 +8,7 @@ void main() {
addBallStories(dashbook);
addLayerStories(dashbook);
addEffectsStories(dashbook);
addErrorComponentStories(dashbook);
addFlutterForestStories(dashbook);
addSparkyScorchStories(dashbook);
addAndroidAcresStories(dashbook);

@ -0,0 +1,24 @@
import 'package:flame/components.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/common/common.dart';
class ErrorComponentGame extends AssetsGame {
ErrorComponentGame({required this.text});
static const description = 'Shows how ErrorComponents are rendered.';
final String text;
@override
Future<void> onLoad() async {
camera.followVector2(Vector2.zero());
await add(ErrorComponent(label: text));
await add(
ErrorComponent.bold(
label: text,
position: Vector2(0, 10),
),
);
}
}

@ -0,0 +1,16 @@
import 'package:dashbook/dashbook.dart';
import 'package:sandbox/common/common.dart';
import 'package:sandbox/stories/error_component/error_component_game.dart';
void addErrorComponentStories(Dashbook dashbook) {
dashbook.storiesOf('ErrorComponent').addGame(
title: 'Basic',
description: ErrorComponentGame.description,
gameBuilder: (context) => ErrorComponentGame(
text: context.textProperty(
'label',
'Oh no, something went wrong!',
),
),
);
}

@ -4,6 +4,7 @@ export 'bottom_group/stories.dart';
export 'boundaries/stories.dart';
export 'dino_desert/stories.dart';
export 'effects/stories.dart';
export 'error_component/stories.dart';
export 'flutter_forest/stories.dart';
export 'google_word/stories.dart';
export 'launch_ramp/stories.dart';

@ -112,7 +112,7 @@ packages:
path: "packages/flame_forge2d"
ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
resolved-ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
url: "https://github.com/flame-engine/flame/"
url: "https://github.com/flame-engine/flame"
source: git
version: "0.11.0"
flutter:
@ -270,6 +270,13 @@ packages:
relative: true
source: path
version: "1.0.0+1"
pinball_ui:
dependency: transitive
description:
path: "../../pinball_ui"
relative: true
source: path
version: "1.0.0+1"
platform:
dependency: transitive
description:
@ -407,7 +414,7 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.20"
version: "6.1.0"
url_launcher_android:
dependency: transitive
description:

@ -11,7 +11,7 @@ dependencies:
flame: ^1.1.1
flame_forge2d:
git:
url: https://github.com/flame-engine/flame/
url: https://github.com/flame-engine/flame
path: packages/flame_forge2d/
ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
flutter:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 95 KiB

@ -0,0 +1,57 @@
// ignore_for_file: cascade_invocations
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
extension _IterableX on Iterable<Component> {
int countTexts(String value) {
return where(
(component) => component is TextComponent && component.text == value,
).length;
}
}
void main() {
group('ErrorComponent', () {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.errorBackground.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
flameTester.test('renders correctly', (game) async {
await game.ensureAdd(ErrorComponent(label: 'Error Message'));
final count = game.descendants().countTexts('Error Message');
expect(count, equals(1));
});
group('when the text is longer than one line', () {
flameTester.test('renders correctly', (game) async {
await game.ensureAdd(
ErrorComponent(
label: 'Error With A Longer Message',
),
);
final count1 = game.descendants().countTexts('Error With A');
final count2 = game.descendants().countTexts('Longer Message');
expect(count1, equals(1));
expect(count2, equals(1));
});
});
group('when using the bold font', () {
flameTester.test('renders correctly', (game) async {
await game.ensureAdd(ErrorComponent.bold(label: 'Error Message'));
final count = game.descendants().countTexts('Error Message');
expect(count, equals(1));
});
});
});
}

@ -0,0 +1,141 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_components/src/components/sparky_computer/behaviors/behaviors.dart';
import '../../../../helpers/helpers.dart';
class _MockSparkyComputerCubit extends Mock implements SparkyComputerCubit {}
class _MockBall extends Mock implements Ball {}
class _MockContact extends Mock implements Contact {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(TestGame.new);
group(
'SparkyComputerSensorBallContactBehavior',
() {
test('can be instantiated', () {
expect(
SparkyComputerSensorBallContactBehavior(),
isA<SparkyComputerSensorBallContactBehavior>(),
);
});
group('beginContact', () {
flameTester.test(
'stops a ball',
(game) async {
final behavior = SparkyComputerSensorBallContactBehavior();
final bloc = _MockSparkyComputerCubit();
whenListen(
bloc,
const Stream<SparkyComputerState>.empty(),
initialState: SparkyComputerState.withoutBall,
);
final sparkyComputer = SparkyComputer.test(
bloc: bloc,
);
await sparkyComputer.add(behavior);
await game.ensureAdd(sparkyComputer);
final ball = _MockBall();
await behavior.beginContact(ball, _MockContact());
verify(ball.stop).called(1);
},
);
flameTester.test(
'emits onBallEntered when contacts with a ball',
(game) async {
final behavior = SparkyComputerSensorBallContactBehavior();
final bloc = _MockSparkyComputerCubit();
whenListen(
bloc,
const Stream<SparkyComputerState>.empty(),
initialState: SparkyComputerState.withoutBall,
);
final sparkyComputer = SparkyComputer.test(
bloc: bloc,
);
await sparkyComputer.add(behavior);
await game.ensureAdd(sparkyComputer);
await behavior.beginContact(_MockBall(), _MockContact());
verify(sparkyComputer.bloc.onBallEntered).called(1);
},
);
flameTester.test(
'adds TimerComponent when contacts with a ball',
(game) async {
final behavior = SparkyComputerSensorBallContactBehavior();
final bloc = _MockSparkyComputerCubit();
whenListen(
bloc,
const Stream<SparkyComputerState>.empty(),
initialState: SparkyComputerState.withoutBall,
);
final sparkyComputer = SparkyComputer.test(
bloc: bloc,
);
await sparkyComputer.add(behavior);
await game.ensureAdd(sparkyComputer);
await behavior.beginContact(_MockBall(), _MockContact());
await game.ready();
expect(
sparkyComputer.firstChild<TimerComponent>(),
isA<TimerComponent>(),
);
},
);
flameTester.test(
'TimerComponent resumes ball and calls onBallTurboCharged onTick',
(game) async {
final behavior = SparkyComputerSensorBallContactBehavior();
final bloc = _MockSparkyComputerCubit();
whenListen(
bloc,
const Stream<SparkyComputerState>.empty(),
initialState: SparkyComputerState.withoutBall,
);
final sparkyComputer = SparkyComputer.test(
bloc: bloc,
);
await sparkyComputer.add(behavior);
await game.ensureAdd(sparkyComputer);
final ball = _MockBall();
await behavior.beginContact(ball, _MockContact());
await game.ready();
game.update(
sparkyComputer.firstChild<TimerComponent>()!.timer.limit,
);
await game.ready();
verify(ball.resume).called(1);
verify(sparkyComputer.bloc.onBallTurboCharged).called(1);
},
);
});
},
);
}

@ -0,0 +1,24 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
void main() {
group(
'SparkyComputerCubit',
() {
blocTest<SparkyComputerCubit, SparkyComputerState>(
'onBallEntered emits withBall',
build: SparkyComputerCubit.new,
act: (bloc) => bloc.onBallEntered(),
expect: () => [SparkyComputerState.withBall],
);
blocTest<SparkyComputerCubit, SparkyComputerState>(
'onBallTurboCharged emits withoutBall',
build: SparkyComputerCubit.new,
act: (bloc) => bloc.onBallTurboCharged(),
expect: () => [SparkyComputerState.withoutBall],
);
},
);
}

@ -0,0 +1,93 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_components/src/components/sparky_computer/behaviors/behaviors.dart';
import '../../../helpers/helpers.dart';
class _MockSparkyComputerCubit extends Mock implements SparkyComputerCubit {}
void main() {
group('SparkyComputer', () {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.sparky.computer.base.keyName,
Assets.images.sparky.computer.top.keyName,
Assets.images.sparky.computer.glow.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
flameTester.test('loads correctly', (game) async {
final component = SparkyComputer();
await game.ensureAdd(component);
expect(game.contains(component), isTrue);
});
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {
await game.images.loadAll(assets);
await game.ensureAdd(SparkyComputer());
await tester.pump();
game.camera
..followVector2(Vector2(0, -20))
..zoom = 7;
},
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/sparky-computer.png'),
);
},
);
// TODO(alestiago): Consider refactoring once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
// ignore: public_member_api_docs
flameTester.test('closes bloc when removed', (game) async {
final bloc = _MockSparkyComputerCubit();
whenListen(
bloc,
const Stream<SparkyComputerState>.empty(),
initialState: SparkyComputerState.withoutBall,
);
when(bloc.close).thenAnswer((_) async {});
final sparkyComputer = SparkyComputer.test(bloc: bloc);
await game.ensureAdd(sparkyComputer);
game.remove(sparkyComputer);
await game.ready();
verify(bloc.close).called(1);
});
group('adds', () {
flameTester.test('new children', (game) async {
final component = Component();
final sparkyComputer = SparkyComputer(
children: [component],
);
await game.ensureAdd(sparkyComputer);
expect(sparkyComputer.children, contains(component));
});
flameTester.test('a SparkyComputerSensorBallContactBehavior',
(game) async {
final sparkyComputer = SparkyComputer();
await game.ensureAdd(sparkyComputer);
expect(
sparkyComputer.children
.whereType<SparkyComputerSensorBallContactBehavior>()
.single,
isNotNull,
);
});
});
});
}

@ -1,45 +0,0 @@
// ignore_for_file: cascade_invocations
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
void main() {
group('SparkyComputer', () {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.sparky.computer.base.keyName,
Assets.images.sparky.computer.top.keyName,
Assets.images.sparky.computer.glow.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
flameTester.test('loads correctly', (game) async {
final component = SparkyComputer();
await game.ensureAdd(component);
expect(game.contains(component), isTrue);
});
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {
await game.images.loadAll(assets);
await game.ensureAdd(SparkyComputer());
await tester.pump();
game.camera
..followVector2(Vector2(0, -20))
..zoom = 7;
},
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/sparky-computer.png'),
);
},
);
});
}

@ -1,4 +1,5 @@
import 'dart:ui';
import 'package:collection/collection.dart' as collection;
import 'package:flame/components.dart';
import 'package:pinball_flame/src/canvas/canvas_wrapper.dart';
@ -56,7 +57,14 @@ class _ZCanvas extends CanvasWrapper {
final List<ZIndex> _zBuffer = [];
/// Postpones the rendering of [ZIndex] component and its children.
void buffer(ZIndex component) => _zBuffer.add(component);
void buffer(ZIndex component) {
final lowerBound = collection.lowerBound<ZIndex>(
_zBuffer,
component,
compare: (a, b) => a.zIndex.compareTo(b.zIndex),
);
_zBuffer.insert(lowerBound, component);
}
/// Renders all [ZIndex] components and their children.
///
@ -69,8 +77,7 @@ class _ZCanvas extends CanvasWrapper {
/// before the second one.
/// {@endtemplate}
void render() => _zBuffer
..sort((a, b) => a.zIndex.compareTo(b.zIndex))
..whereType<Component>().forEach(_render)
..forEach(_render)
..clear();
void _render(Component component) => component.renderTree(canvas);

@ -1,6 +1,8 @@
// ignore_for_file: public_member_api_docs
import 'package:bloc/bloc.dart';
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
class FlameProvider<T> extends Component {
FlameProvider.value(
@ -63,3 +65,15 @@ extension ReadFlameProvider on Component {
return providers.first.provider;
}
}
extension ReadFlameBlocProvider on Component {
B readBloc<B extends BlocBase<S>, S>() {
final providers = ancestors().whereType<FlameBlocProvider<B, S>>();
assert(
providers.isNotEmpty,
'No FlameBlocProvider<$B, $S> available on the component tree',
);
return providers.first.bloc;
}
}

@ -7,10 +7,12 @@ environment:
sdk: ">=2.16.0 <3.0.0"
dependencies:
bloc: ^8.0.0
flame: ^1.1.1
flame_bloc: ^1.4.0
flame_forge2d:
git:
url: https://github.com/flame-engine/flame/
url: https://github.com/flame-engine/flame
path: packages/flame_forge2d/
ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
flutter:

@ -1,11 +1,15 @@
// ignore_for_file: cascade_invocations
import 'package:bloc/bloc.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_flame/pinball_flame.dart';
class _FakeCubit extends Fake implements Cubit<Object> {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(FlameGame.new);
@ -100,4 +104,33 @@ void main() {
);
},
);
group(
'ReadFlameBlocProvider',
() {
flameTester.test('loads provider', (game) async {
final component = Component();
final bloc = _FakeCubit();
final provider = FlameBlocProvider<_FakeCubit, Object>.value(
value: bloc,
children: [component],
);
await game.ensureAdd(provider);
expect(component.readBloc<_FakeCubit, Object>(), equals(bloc));
});
flameTester.test(
'throws assertionError when no provider is found',
(game) async {
final component = Component();
await game.ensureAdd(component);
expect(
() => component.readBloc<_FakeCubit, Object>(),
throwsAssertionError,
);
},
);
},
);
}

@ -245,7 +245,7 @@ packages:
path: "packages/flame_forge2d"
ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
resolved-ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
url: "https://github.com/flame-engine/flame/"
url: "https://github.com/flame-engine/flame"
source: git
version: "0.11.0"
flame_test:
@ -821,4 +821,4 @@ packages:
version: "3.1.0"
sdks:
dart: ">=2.16.0 <3.0.0"
flutter: ">=2.10.0"
flutter: ">=2.10.5"

@ -5,6 +5,7 @@ publish_to: none
environment:
sdk: ">=2.16.0 <3.0.0"
flutter: 2.10.5
dependencies:
authentication_repository:
@ -18,7 +19,7 @@ dependencies:
flame_bloc: ^1.4.0
flame_forge2d:
git:
url: https://github.com/flame-engine/flame/
url: https://github.com/flame-engine/flame
path: packages/flame_forge2d/
ref: a50d4a1e7d9eaf66726ed1bb9894c9d495547d8f
flutter:

@ -0,0 +1,186 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.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_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_flame/pinball_flame.dart';
class _TestGame extends Forge2DGame {
Future<void> pump(
BonusNoiseBehavior child, {
required PinballPlayer player,
required GameBloc bloc,
}) {
return ensureAdd(
FlameBlocProvider<GameBloc, GameState>.value(
value: bloc,
children: [
FlameProvider<PinballPlayer>.value(
player,
children: [
child,
],
),
],
),
);
}
}
class _MockPinballPlayer extends Mock implements PinballPlayer {}
class _MockGameBloc extends Mock implements GameBloc {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('BonusNoiseBehavior', () {
late PinballPlayer player;
late GameBloc bloc;
final flameTester = FlameTester(_TestGame.new);
setUpAll(() {
registerFallbackValue(PinballAudio.google);
});
setUp(() {
player = _MockPinballPlayer();
when(() => player.play(any())).thenAnswer((_) {});
bloc = _MockGameBloc();
});
flameTester.testGameWidget(
'plays google sound',
setUp: (game, _) async {
const state = GameState(
totalScore: 0,
roundScore: 0,
multiplier: 1,
rounds: 0,
bonusHistory: [GameBonus.googleWord],
status: GameStatus.playing,
);
const initialState = GameState.initial();
whenListen(
bloc,
Stream.fromIterable([initialState, state]),
initialState: initialState,
);
final behavior = BonusNoiseBehavior();
await game.pump(behavior, player: player, bloc: bloc);
},
verify: (_, __) async {
verify(() => player.play(PinballAudio.google)).called(1);
},
);
flameTester.testGameWidget(
'plays sparky sound',
setUp: (game, _) async {
const state = GameState(
totalScore: 0,
roundScore: 0,
multiplier: 1,
rounds: 0,
bonusHistory: [GameBonus.sparkyTurboCharge],
status: GameStatus.playing,
);
const initialState = GameState.initial();
whenListen(
bloc,
Stream.fromIterable([initialState, state]),
initialState: initialState,
);
final behavior = BonusNoiseBehavior();
await game.pump(behavior, player: player, bloc: bloc);
},
verify: (_, __) async {
verify(() => player.play(PinballAudio.sparky)).called(1);
},
);
flameTester.testGameWidget(
'plays dino chomp sound',
setUp: (game, _) async {
const state = GameState(
totalScore: 0,
roundScore: 0,
multiplier: 1,
rounds: 0,
bonusHistory: [GameBonus.dinoChomp],
status: GameStatus.playing,
);
const initialState = GameState.initial();
whenListen(
bloc,
Stream.fromIterable([initialState, state]),
initialState: initialState,
);
final behavior = BonusNoiseBehavior();
await game.pump(behavior, player: player, bloc: bloc);
},
verify: (_, __) async {
// TODO(erickzanardo): Change when the sound is implemented
verifyNever(() => player.play(any()));
},
);
flameTester.testGameWidget(
'plays android spaceship sound',
setUp: (game, _) async {
const state = GameState(
totalScore: 0,
roundScore: 0,
multiplier: 1,
rounds: 0,
bonusHistory: [GameBonus.androidSpaceship],
status: GameStatus.playing,
);
const initialState = GameState.initial();
whenListen(
bloc,
Stream.fromIterable([initialState, state]),
initialState: initialState,
);
final behavior = BonusNoiseBehavior();
await game.pump(behavior, player: player, bloc: bloc);
},
verify: (_, __) async {
// TODO(erickzanardo): Change when the sound is implemented
verifyNever(() => player.play(any()));
},
);
flameTester.testGameWidget(
'plays dash nest sound',
setUp: (game, _) async {
const state = GameState(
totalScore: 0,
roundScore: 0,
multiplier: 1,
rounds: 0,
bonusHistory: [GameBonus.dashNest],
status: GameStatus.playing,
);
const initialState = GameState.initial();
whenListen(
bloc,
Stream.fromIterable([initialState, state]),
initialState: initialState,
);
final behavior = BonusNoiseBehavior();
await game.pump(behavior, player: player, bloc: bloc);
},
verify: (_, __) async {
// TODO(erickzanardo): Change when the sound is implemented
verifyNever(() => player.play(any()));
},
);
});
}

@ -1,71 +0,0 @@
// ignore_for_file: cascade_invocations
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme;
class _TestGame extends Forge2DGame {
@override
Future<void> onLoad() async {
images.prefix = '';
await images.load(theme.Assets.images.dash.ball.keyName);
}
Future<void> pump(Ball child, {required GameBloc gameBloc}) async {
await ensureAdd(
FlameBlocProvider<GameBloc, GameState>.value(
value: gameBloc,
children: [child],
),
);
}
}
class _MockGameBloc extends Mock implements GameBloc {}
class _MockBall extends Mock implements Ball {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('BallController', () {
late Ball ball;
late GameBloc gameBloc;
setUp(() {
ball = Ball();
gameBloc = _MockGameBloc();
});
final flameBlocTester = FlameTester(_TestGame.new);
test('can be instantiated', () {
expect(
BallController(_MockBall()),
isA<BallController>(),
);
});
flameBlocTester.testGameWidget(
'turboCharge adds TurboChargeActivated',
setUp: (game, tester) async {
await game.onLoad();
final controller = BallController(ball);
await ball.add(controller);
await game.pump(ball, gameBloc: gameBloc);
await controller.turboCharge();
},
verify: (game, tester) async {
verify(() => gameBloc.add(const SparkyTurboChargeActivated()))
.called(1);
},
);
});
}

@ -0,0 +1,86 @@
// ignore_for_file: cascade_invocations
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/components/sparky_scorch/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
class _TestGame extends Forge2DGame {
@override
Future<void> onLoad() async {
images.prefix = '';
await images.loadAll([
Assets.images.sparky.computer.top.keyName,
Assets.images.sparky.computer.base.keyName,
Assets.images.sparky.computer.glow.keyName,
Assets.images.sparky.animatronic.keyName,
Assets.images.sparky.bumper.a.lit.keyName,
Assets.images.sparky.bumper.a.dimmed.keyName,
Assets.images.sparky.bumper.b.lit.keyName,
Assets.images.sparky.bumper.b.dimmed.keyName,
Assets.images.sparky.bumper.c.lit.keyName,
Assets.images.sparky.bumper.c.dimmed.keyName,
]);
}
Future<void> pump(
SparkyScorch child, {
required GameBloc gameBloc,
}) async {
// Not needed once https://github.com/flame-engine/flame/issues/1607
// is fixed
await onLoad();
await ensureAdd(
FlameBlocProvider<GameBloc, GameState>.value(
value: gameBloc,
children: [child],
),
);
}
}
class _MockGameBloc extends Mock implements GameBloc {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SparkyComputerBonusBehavior', () {
late GameBloc gameBloc;
setUp(() {
gameBloc = _MockGameBloc();
});
final flameTester = FlameTester(_TestGame.new);
flameTester.testGameWidget(
'adds GameBonus.sparkyTurboCharge to the game and plays animatronic '
'when SparkyComputerState.withBall is emitted',
setUp: (game, tester) async {
final behavior = SparkyComputerBonusBehavior();
final parent = SparkyScorch.test();
final sparkyComputer = SparkyComputer();
final animatronic = SparkyAnimatronic();
await parent.addAll([
sparkyComputer,
animatronic,
]);
await game.pump(parent, gameBloc: gameBloc);
await parent.ensureAdd(behavior);
sparkyComputer.bloc.onBallEntered();
await tester.pump();
verify(
() => gameBloc.add(const BonusActivated(GameBonus.sparkyTurboCharge)),
).called(1);
expect(animatronic.playing, isTrue);
},
);
});
}

@ -1,10 +1,11 @@
// ignore_for_file: cascade_invocations
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/behaviors/behaviors.dart';
import 'package:pinball/game/components/sparky_scorch/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
@ -25,13 +26,16 @@ class _TestGame extends Forge2DGame {
Assets.images.sparky.bumper.c.dimmed.keyName,
]);
}
}
class _MockControlledBall extends Mock implements ControlledBall {}
class _MockBallController extends Mock implements BallController {}
class _MockContact extends Mock implements Contact {}
Future<void> pump(SparkyScorch child) async {
await ensureAdd(
FlameBlocProvider<GameBloc, GameState>.value(
value: GameBloc(),
children: [child],
),
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -41,15 +45,18 @@ void main() {
group('SparkyScorch', () {
flameTester.test('loads correctly', (game) async {
final component = SparkyScorch();
await game.ensureAdd(component);
expect(game.contains(component), isTrue);
await game.pump(component);
expect(
game.descendants().whereType<SparkyScorch>().length,
equals(1),
);
});
group('loads', () {
flameTester.test(
'a SparkyComputer',
(game) async {
await game.ensureAdd(SparkyScorch());
await game.pump(SparkyScorch());
expect(
game.descendants().whereType<SparkyComputer>().length,
equals(1),
@ -60,7 +67,7 @@ void main() {
flameTester.test(
'a SparkyAnimatronic',
(game) async {
await game.ensureAdd(SparkyScorch());
await game.pump(SparkyScorch());
expect(
game.descendants().whereType<SparkyAnimatronic>().length,
equals(1),
@ -71,7 +78,7 @@ void main() {
flameTester.test(
'three SparkyBumper',
(game) async {
await game.ensureAdd(SparkyScorch());
await game.pump(SparkyScorch());
expect(
game.descendants().whereType<SparkyBumper>().length,
equals(3),
@ -82,7 +89,7 @@ void main() {
flameTester.test(
'three SparkyBumpers with BumperNoiseBehavior',
(game) async {
await game.ensureAdd(SparkyScorch());
await game.pump(SparkyScorch());
final bumpers = game.descendants().whereType<SparkyBumper>();
for (final bumper in bumpers) {
expect(
@ -93,41 +100,30 @@ void main() {
},
);
});
});
group('SparkyComputerSensor', () {
flameTester.test('calls turboCharge', (game) async {
final sensor = SparkyComputerSensor();
final ball = _MockControlledBall();
final controller = _MockBallController();
when(() => ball.controller).thenReturn(controller);
when(controller.turboCharge).thenAnswer((_) async {});
await game.ensureAddAll([
sensor,
SparkyAnimatronic(),
]);
sensor.beginContact(ball, _MockContact());
verify(() => ball.controller.turboCharge()).called(1);
});
group('adds', () {
flameTester.test(
'ScoringContactBehavior to SparkyComputer',
(game) async {
await game.pump(SparkyScorch());
flameTester.test('plays SparkyAnimatronic', (game) async {
final sensor = SparkyComputerSensor();
final sparkyAnimatronic = SparkyAnimatronic();
final ball = _MockControlledBall();
final controller = _MockBallController();
when(() => ball.controller).thenReturn(controller);
when(controller.turboCharge).thenAnswer((_) async {});
await game.ensureAddAll([
sensor,
sparkyAnimatronic,
]);
final sparkyComputer =
game.descendants().whereType<SparkyComputer>().single;
expect(
sparkyComputer.firstChild<ScoringContactBehavior>(),
isNotNull,
);
},
);
expect(sparkyAnimatronic.playing, isFalse);
sensor.beginContact(ball, _MockContact());
expect(sparkyAnimatronic.playing, isTrue);
flameTester.test('a SparkyComputerBonusBehavior', (game) async {
final sparkyScorch = SparkyScorch();
await game.pump(sparkyScorch);
expect(
sparkyScorch.children.whereType<SparkyComputerBonusBehavior>().single,
isNotNull,
);
});
});
});
}

@ -409,14 +409,12 @@ void main() {
when(() => tapUpEvent.raw).thenReturn(raw);
await game.ready();
final previousBalls =
game.descendants().whereType<ControlledBall>().toList();
final previousBalls = game.descendants().whereType<Ball>().toList();
game.onTapUp(0, tapUpEvent);
await game.ready();
final currentBalls =
game.descendants().whereType<ControlledBall>().toList();
final currentBalls = game.descendants().whereType<Ball>().toList();
expect(
currentBalls.length,
@ -475,14 +473,13 @@ void main() {
game.lineEnd = endPosition;
await game.ready();
final previousBalls =
game.descendants().whereType<ControlledBall>().toList();
final previousBalls = game.descendants().whereType<Ball>().toList();
game.onPanEnd(_MockDragEndInfo());
await game.ready();
expect(
game.descendants().whereType<ControlledBall>().length,
game.descendants().whereType<Ball>().length,
equals(previousBalls.length + 1),
);
},

@ -1,5 +1,7 @@
// ignore_for_file: prefer_const_constructors
import 'dart:ui';
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
@ -8,7 +10,9 @@ import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/assets_manager/assets_manager.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/gen/gen.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/more_information/more_information.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball/start_game/start_game.dart';
import 'package:pinball_audio/pinball_audio.dart';
@ -80,6 +84,7 @@ void main() {
await tester.pumpApp(
PinballGamePage(),
characterThemeCubit: characterThemeCubit,
gameBloc: gameBloc,
);
expect(find.byType(PinballGameView), findsOneWidget);
@ -168,6 +173,7 @@ void main() {
),
),
characterThemeCubit: characterThemeCubit,
gameBloc: gameBloc,
);
await tester.tap(find.text('Tap me'));
@ -291,5 +297,90 @@ void main() {
findsNothing,
);
});
testWidgets('keep focus on game when mouse hovers over it', (tester) async {
final startGameState = StartGameState.initial().copyWith(
status: StartGameStatus.play,
);
final gameState = GameState.initial().copyWith(
status: GameStatus.gameOver,
);
whenListen(
startGameBloc,
Stream.value(startGameState),
initialState: startGameState,
);
whenListen(
gameBloc,
Stream.value(gameState),
initialState: gameState,
);
await tester.pumpApp(
PinballGameView(game: game),
gameBloc: gameBloc,
startGameBloc: startGameBloc,
);
game.focusNode.unfocus();
await tester.pump();
expect(game.focusNode.hasFocus, isFalse);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await gesture.moveTo((game.size / 2).toOffset());
await tester.pump();
expect(game.focusNode.hasFocus, isTrue);
});
group('info icon', () {
testWidgets('renders on game over', (tester) async {
final gameState = GameState.initial().copyWith(
status: GameStatus.gameOver,
);
whenListen(
gameBloc,
Stream.value(gameState),
initialState: gameState,
);
await tester.pumpApp(
PinballGameView(game: game),
gameBloc: gameBloc,
startGameBloc: startGameBloc,
);
expect(
find.image(Assets.images.linkBox.infoIcon),
findsOneWidget,
);
});
testWidgets('opens MoreInformationDialog when tapped', (tester) async {
final gameState = GameState.initial().copyWith(
status: GameStatus.gameOver,
);
whenListen(
gameBloc,
Stream.value(gameState),
initialState: gameState,
);
await tester.pumpApp(
PinballGameView(game: game),
gameBloc: gameBloc,
startGameBloc: startGameBloc,
);
await tester.tap(find.byType(IconButton));
await tester.pump();
expect(
find.byType(MoreInformationDialog),
findsOneWidget,
);
});
});
});
}

@ -2,7 +2,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/footer/footer.dart';
import 'package:pinball/more_information/more_information.dart';
import 'package:pinball_ui/pinball_ui.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
@ -28,15 +28,34 @@ class _MockUrlLauncher extends Mock
implements UrlLauncherPlatform {}
void main() {
group('Footer', () {
group('MoreInformationDialog', () {
late UrlLauncherPlatform urlLauncher;
setUp(() async {
urlLauncher = _MockUrlLauncher();
UrlLauncherPlatform.instance = urlLauncher;
});
group('showMoreInformationDialog', () {
testWidgets('inflates the dialog', (tester) async {
await tester.pumpApp(
Builder(
builder: (context) {
return TextButton(
onPressed: () => showMoreInformationDialog(context),
child: const Text('test'),
);
},
),
);
await tester.tap(find.text('test'));
await tester.pump();
expect(find.byType(MoreInformationDialog), findsOneWidget);
});
});
testWidgets('renders "Made with..." and "Google I/O"', (tester) async {
await tester.pumpApp(const Footer());
await tester.pumpApp(const MoreInformationDialog());
expect(find.text('Google I/O'), findsOneWidget);
expect(
find.byWidgetPredicate(
@ -63,7 +82,7 @@ void main() {
headers: any(named: 'headers'),
),
).thenAnswer((_) async => true);
await tester.pumpApp(const Footer());
await tester.pumpApp(const MoreInformationDialog());
final flutterTextFinder = find.byWidgetPredicate(
(widget) => widget is RichText && _tapTextSpan(widget, 'Flutter'),
);
@ -98,7 +117,7 @@ void main() {
headers: any(named: 'headers'),
),
).thenAnswer((_) async => true);
await tester.pumpApp(const Footer());
await tester.pumpApp(const MoreInformationDialog());
final firebaseTextFinder = find.byWidgetPredicate(
(widget) => widget is RichText && _tapTextSpan(widget, 'Firebase'),
);
@ -117,5 +136,50 @@ void main() {
);
},
);
<String, String>{
'Open Source Code': 'https://github.com/VGVentures/pinball',
'Google I/O': 'https://events.google.com/io/',
'Flutter Games': 'http://flutter.dev/games',
'How its made':
'https://medium.com/flutter/i-o-pinball-powered-by-flutter-and-firebase-d22423f3f5d',
'Terms of Service': 'https://policies.google.com/terms',
'Privacy Policy': 'https://policies.google.com/privacy',
}.forEach((text, link) {
testWidgets(
'tapping on "$text" opens the link - $link',
(tester) async {
when(() => urlLauncher.canLaunch(any()))
.thenAnswer((_) async => true);
when(
() => urlLauncher.launch(
link,
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
).thenAnswer((_) async => true);
await tester.pumpApp(const MoreInformationDialog());
await tester.tap(find.text(text));
await tester.pumpAndSettle();
verify(
() => urlLauncher.launch(
link,
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
);
},
);
});
});
}
Loading…
Cancel
Save