Merge branch 'main' into chore/add-google-assets

pull/286/head
Allison Ryan 3 years ago
commit 69219b233e

@ -0,0 +1,22 @@
name: authentication_repository
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
paths:
- "packages/authentication_repository/**"
- ".github/workflows/authentication_repository.yaml"
pull_request:
paths:
- "packages/authentication_repository/**"
- ".github/workflows/authentication_repository.yaml"
jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
working_directory: packages/authentication_repository

@ -0,0 +1,25 @@
name: deploy
on:
push:
branches:
- main
jobs:
deploy-dev:
runs-on: ubuntu-latest
name: Deploy Development
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter packages get
- run: flutter build web --target lib/main_development.dart --web-renderer canvaskit --release
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: "${{ secrets.GITHUB_TOKEN }}"
firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_PINBALL_DEV }}"
channelId: live
projectId: pinball-dev
target: ashehwkdkdjruejdnensjsjdne

@ -1,5 +1,9 @@
name: geometry name: geometry
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
paths: paths:

@ -1,5 +1,9 @@
name: leaderboard_repository name: leaderboard_repository
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
paths: paths:

@ -1,5 +1,9 @@
name: pinball name: pinball
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: [pull_request, push] on: [pull_request, push]
jobs: jobs:

@ -1,5 +1,9 @@
name: pinball_audio name: pinball_audio
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
paths: paths:

@ -1,5 +1,9 @@
name: pinball_components name: pinball_components
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
paths: paths:

@ -1,5 +1,9 @@
name: pinball_flame name: pinball_flame
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
paths: paths:

@ -1,5 +1,9 @@
name: pinball_theme name: pinball_theme
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
paths: paths:

@ -0,0 +1,23 @@
name: pinball_ui
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
paths:
- "packages/pinball_ui/**"
- ".github/workflows/pinball_ui.yaml"
pull_request:
paths:
- "packages/pinball_ui/**"
- ".github/workflows/pinball_ui.yaml"
jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
working_directory: packages/pinball_ui
coverage_excludes: "lib/gen/*.dart"

@ -0,0 +1,23 @@
name: platform_helper
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
paths:
- "packages/platform_helper/**"
- ".github/workflows/platform_helper.yaml"
pull_request:
paths:
- "packages/platform_helper/**"
- ".github/workflows/platform_helper.yaml"
jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
working_directory: packages/platform_helper
coverage_excludes: "lib/gen/*.dart"

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -7,24 +7,29 @@
// ignore_for_file: public_member_api_docs // ignore_for_file: public_member_api_docs
import 'package:authentication_repository/authentication_repository.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/theme/theme.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_ui/pinball_ui.dart';
class App extends StatelessWidget { class App extends StatelessWidget {
const App({ const App({
Key? key, Key? key,
required AuthenticationRepository authenticationRepository,
required LeaderboardRepository leaderboardRepository, required LeaderboardRepository leaderboardRepository,
required PinballAudio pinballAudio, required PinballAudio pinballAudio,
}) : _leaderboardRepository = leaderboardRepository, }) : _authenticationRepository = authenticationRepository,
_leaderboardRepository = leaderboardRepository,
_pinballAudio = pinballAudio, _pinballAudio = pinballAudio,
super(key: key); super(key: key);
final AuthenticationRepository _authenticationRepository;
final LeaderboardRepository _leaderboardRepository; final LeaderboardRepository _leaderboardRepository;
final PinballAudio _pinballAudio; final PinballAudio _pinballAudio;
@ -32,19 +37,21 @@ class App extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiRepositoryProvider( return MultiRepositoryProvider(
providers: [ providers: [
RepositoryProvider.value(value: _authenticationRepository),
RepositoryProvider.value(value: _leaderboardRepository), RepositoryProvider.value(value: _leaderboardRepository),
RepositoryProvider.value(value: _pinballAudio), RepositoryProvider.value(value: _pinballAudio),
], ],
child: BlocProvider( child: BlocProvider(
create: (context) => ThemeCubit(), create: (context) => CharacterThemeCubit(),
child: const MaterialApp( child: MaterialApp(
title: 'I/O Pinball', title: 'I/O Pinball',
localizationsDelegates: [ theme: PinballTheme.standard,
localizationsDelegates: const [
AppLocalizations.delegate, AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: PinballGamePage(), home: const PinballGamePage(),
), ),
), ),
); );

@ -12,6 +12,7 @@ import 'dart:developer';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class AppBlocObserver extends BlocObserver { class AppBlocObserver extends BlocObserver {
@ -28,9 +29,12 @@ class AppBlocObserver extends BlocObserver {
} }
} }
Future<void> bootstrap( typedef BootstrapBuilder = Future<Widget> Function(
Future<Widget> Function(FirebaseFirestore firestore) builder, FirebaseFirestore firestore,
) async { FirebaseAuth firebaseAuth,
);
Future<void> bootstrap(BootstrapBuilder builder) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) { FlutterError.onError = (details) {
log(details.exceptionAsString(), stackTrace: details.stack); log(details.exceptionAsString(), stackTrace: details.stack);
@ -39,7 +43,12 @@ Future<void> bootstrap(
await runZonedGuarded( await runZonedGuarded(
() async { () async {
await BlocOverrides.runZoned( await BlocOverrides.runZoned(
() async => runApp(await builder(FirebaseFirestore.instance)), () async => runApp(
await builder(
FirebaseFirestore.instance,
FirebaseAuth.instance,
),
),
blocObserver: AppBlocObserver(), blocObserver: AppBlocObserver(),
); );
}, },

@ -0,0 +1,76 @@
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,
),
),
],
),
);
}
}

@ -1,5 +1,5 @@
// ignore_for_file: public_member_api_docs // ignore_for_file: public_member_api_docs
import 'dart:math' as math;
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
@ -9,19 +9,41 @@ part 'game_state.dart';
class GameBloc extends Bloc<GameEvent, GameState> { class GameBloc extends Bloc<GameEvent, GameState> {
GameBloc() : super(const GameState.initial()) { GameBloc() : super(const GameState.initial()) {
on<BallLost>(_onBallLost); on<RoundLost>(_onRoundLost);
on<Scored>(_onScored); on<Scored>(_onScored);
on<MultiplierIncreased>(_onIncreasedMultiplier);
on<BonusActivated>(_onBonusActivated); on<BonusActivated>(_onBonusActivated);
on<SparkyTurboChargeActivated>(_onSparkyTurboChargeActivated); on<SparkyTurboChargeActivated>(_onSparkyTurboChargeActivated);
} }
void _onBallLost(BallLost event, Emitter emit) { void _onRoundLost(RoundLost event, Emitter emit) {
emit(state.copyWith(balls: state.balls - 1)); final score = state.score * state.multiplier;
final roundsLeft = math.max(state.rounds - 1, 0);
emit(
state.copyWith(
score: score,
multiplier: 1,
rounds: roundsLeft,
),
);
} }
void _onScored(Scored event, Emitter emit) { void _onScored(Scored event, Emitter emit) {
if (!state.isGameOver) { if (!state.isGameOver) {
emit(state.copyWith(score: state.score + event.points)); emit(
state.copyWith(score: state.score + event.points),
);
}
}
void _onIncreasedMultiplier(MultiplierIncreased event, Emitter emit) {
if (!state.isGameOver) {
emit(
state.copyWith(
multiplier: math.min(state.multiplier + 1, 6),
),
);
} }
} }

@ -7,12 +7,12 @@ abstract class GameEvent extends Equatable {
const GameEvent(); const GameEvent();
} }
/// {@template ball_lost_game_event} /// {@template round_lost_game_event}
/// Event added when a user drops a ball off the screen. /// Event added when a user drops all balls off the screen and loses a round.
/// {@endtemplate} /// {@endtemplate}
class BallLost extends GameEvent { class RoundLost extends GameEvent {
/// {@macro ball_lost_game_event} /// {@macro round_lost_game_event}
const BallLost(); const RoundLost();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
@ -48,3 +48,14 @@ class SparkyTurboChargeActivated extends GameEvent {
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// {@template multiplier_increased_game_event}
/// Added when a multiplier is gained.
/// {@endtemplate}
class MultiplierIncreased extends GameEvent {
/// {@macro multiplier_increased_game_event}
const MultiplierIncreased();
@override
List<Object?> get props => [];
}

@ -12,6 +12,12 @@ enum GameBonus {
/// Bonus achieved when a ball enters Sparky's computer. /// Bonus achieved when a ball enters Sparky's computer.
sparkyTurboCharge, sparkyTurboCharge,
/// Bonus achieved when the ball goes in the dino mouth.
dinoChomp,
/// Bonus achieved when a ball enters the android spaceship.
androidSpaceship,
} }
/// {@template game_state} /// {@template game_state}
@ -21,34 +27,42 @@ class GameState extends Equatable {
/// {@macro game_state} /// {@macro game_state}
const GameState({ const GameState({
required this.score, required this.score,
required this.balls, required this.multiplier,
required this.rounds,
required this.bonusHistory, required this.bonusHistory,
}) : assert(score >= 0, "Score can't be negative"), }) : assert(score >= 0, "Score can't be negative"),
assert(balls >= 0, "Number of balls can't be negative"); assert(multiplier > 0, 'Multiplier must be greater than zero'),
assert(rounds >= 0, "Number of rounds can't be negative");
const GameState.initial() const GameState.initial()
: score = 0, : score = 0,
balls = 3, multiplier = 1,
rounds = 3,
bonusHistory = const []; bonusHistory = const [];
/// The current score of the game. /// The current score of the game.
final int score; final int score;
/// The number of balls left in the game. /// The current multiplier for the score.
final int multiplier;
/// The number of rounds left in the game.
/// ///
/// When the number of balls is 0, the game is over. /// When the number of rounds is 0, the game is over.
final int balls; final int rounds;
/// Holds the history of all the [GameBonus]es earned by the player during a /// Holds the history of all the [GameBonus]es earned by the player during a
/// PinballGame. /// PinballGame.
final List<GameBonus> bonusHistory; final List<GameBonus> bonusHistory;
/// Determines when the game is over. /// Determines when the game is over.
bool get isGameOver => balls == 0; bool get isGameOver => rounds == 0;
GameState copyWith({ GameState copyWith({
int? score, int? score,
int? multiplier,
int? balls, int? balls,
int? rounds,
List<GameBonus>? bonusHistory, List<GameBonus>? bonusHistory,
}) { }) {
assert( assert(
@ -58,7 +72,8 @@ class GameState extends Equatable {
return GameState( return GameState(
score: score ?? this.score, score: score ?? this.score,
balls: balls ?? this.balls, multiplier: multiplier ?? this.multiplier,
rounds: rounds ?? this.rounds,
bonusHistory: bonusHistory ?? this.bonusHistory, bonusHistory: bonusHistory ?? this.bonusHistory,
); );
} }
@ -66,7 +81,8 @@ class GameState extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
score, score,
balls, multiplier,
rounds,
bonusHistory, bonusHistory,
]; ];
} }

@ -1,60 +0,0 @@
// 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/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template alien_zone}
/// Area positioned below [Spaceship] where the [Ball]
/// can bounce off [AlienBumper]s.
///
/// When a [Ball] hits an [AlienBumper], the bumper animates.
/// {@endtemplate}
class AlienZone extends Component with HasGameRef<PinballGame> {
/// {@macro alien_zone}
AlienZone();
@override
Future<void> onLoad() async {
await super.onLoad();
gameRef.addContactCallback(AlienBumperBallContactCallback());
final lowerBumper = _AlienBumper.a()
..initialPosition = Vector2(-32.52, -9.1);
final upperBumper = _AlienBumper.b()
..initialPosition = Vector2(-22.89, -17.35);
await addAll([
lowerBumper,
upperBumper,
]);
}
}
// TODO(alestiago): Revisit ScorePoints logic once the FlameForge2D
// ContactCallback process is enhanced.
class _AlienBumper extends AlienBumper with ScorePoints {
_AlienBumper.a() : super.a();
_AlienBumper.b() : super.b();
@override
int get points => 20;
}
/// Listens when a [Ball] bounces against an [AlienBumper].
@visibleForTesting
class AlienBumperBallContactCallback
extends ContactCallback<AlienBumper, Ball> {
@override
void begin(
AlienBumper alienBumper,
Ball _,
Contact __,
) {
alienBumper.animate();
}
}

@ -0,0 +1,36 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template android_acres}
/// Area positioned on the left side of the board containing the
/// [AndroidSpaceship], [SpaceshipRamp], [SpaceshipRail], and [AndroidBumper]s.
/// {@endtemplate}
class AndroidAcres extends Component {
/// {@macro android_acres}
AndroidAcres()
: super(
children: [
SpaceshipRamp(),
SpaceshipRail(),
AndroidSpaceship(position: Vector2(-26.5, -28.5)),
AndroidBumper.a(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-25, 1.3),
AndroidBumper.b(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-32.8, -9.2),
AndroidBumper.cow(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(-20.5, -13.8),
],
);
}

@ -1,95 +0,0 @@
import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template board}
/// The main flat surface of the [PinballGame].
/// {endtemplate}
class Board extends Component {
/// {@macro board}
// TODO(alestiago): Make Board a Blueprint and sort out priorities.
Board() : super(priority: 1);
@override
Future<void> onLoad() async {
// TODO(allisonryan0002): add bottom group and flutter forest to pinball
// game directly. Then remove board.
final bottomGroup = _BottomGroup();
final flutterForest = FlutterForest();
// TODO(alestiago): adjust positioning to real design.
// TODO(alestiago): add dino in pinball game.
final dino = ChromeDino()
..initialPosition = Vector2(
BoardDimensions.bounds.center.dx + 25,
BoardDimensions.bounds.center.dy - 10,
);
await addAll([
bottomGroup,
dino,
flutterForest,
]);
}
}
/// {@template bottom_group}
/// Grouping of the board's bottom [Component]s.
///
/// The [_BottomGroup] consists of[Flipper]s, [Baseboard]s and [Kicker]s.
/// {@endtemplate}
// TODO(alestiago): Consider renaming once entire Board is defined.
class _BottomGroup extends Component {
/// {@macro bottom_group}
_BottomGroup() : super(priority: RenderPriority.bottomGroup);
@override
Future<void> onLoad() async {
final rightSide = _BottomGroupSide(
side: BoardSide.right,
);
final leftSide = _BottomGroupSide(
side: BoardSide.left,
);
await addAll([rightSide, leftSide]);
}
}
/// {@template bottom_group_side}
/// Group with one side of [_BottomGroup]'s symmetric [Component]s.
///
/// For example, [Flipper]s are symmetric components.
/// {@endtemplate}
class _BottomGroupSide extends Component {
/// {@macro bottom_group_side}
_BottomGroupSide({
required BoardSide side,
}) : _side = side;
final BoardSide _side;
@override
Future<void> onLoad() async {
final direction = _side.direction;
final centerXAdjustment = _side.isLeft ? 0 : -6.5;
final flipper = ControlledFlipper(
side: _side,
)..initialPosition = Vector2((11.8 * direction) + centerXAdjustment, 43.6);
final baseboard = Baseboard(side: _side)
..initialPosition = Vector2(
(25.58 * direction) + centerXAdjustment,
28.69,
);
final kicker = Kicker(
side: _side,
)..initialPosition = Vector2(
(22.4 * direction) + centerXAdjustment,
25,
);
await addAll([flipper, baseboard, kicker]);
}
}

@ -0,0 +1,63 @@
import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template bottom_group}
/// Grouping of the board's symmetrical bottom [Component]s.
///
/// The [BottomGroup] consists of [Flipper]s, [Baseboard]s and [Kicker]s.
/// {@endtemplate}
// TODO(allisonryan0002): Consider renaming.
class BottomGroup extends Component with ZIndex {
/// {@macro bottom_group}
BottomGroup()
: super(
children: [
_BottomGroupSide(side: BoardSide.right),
_BottomGroupSide(side: BoardSide.left),
],
) {
zIndex = ZIndexes.bottomGroup;
}
}
/// {@template bottom_group_side}
/// Group with one side of [BottomGroup]'s symmetric [Component]s.
///
/// For example, [Flipper]s are symmetric components.
/// {@endtemplate}
class _BottomGroupSide extends Component {
/// {@macro bottom_group_side}
_BottomGroupSide({
required BoardSide side,
}) : _side = side;
final BoardSide _side;
@override
Future<void> onLoad() async {
final direction = _side.direction;
final centerXAdjustment = _side.isLeft ? 0 : -6.66;
final flipper = ControlledFlipper(
side: _side,
)..initialPosition = Vector2((11.8 * direction) + centerXAdjustment, 43.6);
final baseboard = Baseboard(side: _side)
..initialPosition = Vector2(
(25.58 * direction) + centerXAdjustment,
28.71,
);
final kicker = Kicker(
side: _side,
children: [
ScoringBehavior(points: 5000)..applyTo(['bouncy_edge']),
],
)..initialPosition = Vector2(
(22.64 * direction) + centerXAdjustment,
25.1,
);
await addAll([flipper, baseboard, kicker]);
}
}

@ -1,13 +1,15 @@
export 'alien_zone.dart'; export 'android_acres.dart';
export 'board.dart'; export 'bottom_group.dart';
export 'camera_controller.dart'; export 'camera_controller.dart';
export 'controlled_ball.dart'; export 'controlled_ball.dart';
export 'controlled_flipper.dart'; export 'controlled_flipper.dart';
export 'controlled_plunger.dart'; export 'controlled_plunger.dart';
export 'flutter_forest.dart'; export 'dino_desert.dart';
export 'drain.dart';
export 'flutter_forest/flutter_forest.dart';
export 'game_flow_controller.dart'; export 'game_flow_controller.dart';
export 'google_word.dart'; export 'google_word/google_word.dart';
export 'launcher.dart'; export 'launcher.dart';
export 'score_points.dart'; export 'multipliers/multipliers.dart';
export 'sparky_fire_zone.dart'; export 'scoring_behavior.dart';
export 'wall.dart'; export 'sparky_scorch.dart';

@ -1,5 +1,6 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_forge2d/forge2d_game.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
@ -8,36 +9,34 @@ import 'package:pinball_theme/pinball_theme.dart';
/// {@template controlled_ball} /// {@template controlled_ball}
/// A [Ball] with a [BallController] attached. /// 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} /// {@endtemplate}
class ControlledBall extends Ball with Controls<BallController> { class ControlledBall extends Ball with Controls<BallController> {
/// A [Ball] that launches from the [Plunger]. /// A [Ball] that launches from the [Plunger].
///
/// When a launched [Ball] is lost, it will decrease the [GameState.balls]
/// count, and a new [Ball] is spawned.
ControlledBall.launch({ ControlledBall.launch({
required PinballTheme theme, required CharacterTheme characterTheme,
}) : super(baseColor: theme.characterTheme.ballColor) { }) : super(baseColor: characterTheme.ballColor) {
controller = BallController(this); controller = BallController(this);
priority = RenderPriority.ballOnLaunchRamp;
layer = Layer.launcher; layer = Layer.launcher;
zIndex = ZIndexes.ballOnLaunchRamp;
} }
/// {@template bonus_ball} /// {@template bonus_ball}
/// {@macro controlled_ball} /// {@macro controlled_ball}
///
/// When a bonus [Ball] is lost, the [GameState.balls] doesn't change.
/// {@endtemplate} /// {@endtemplate}
ControlledBall.bonus({ ControlledBall.bonus({
required PinballTheme theme, required CharacterTheme characterTheme,
}) : super(baseColor: theme.characterTheme.ballColor) { }) : super(baseColor: characterTheme.ballColor) {
controller = BallController(this); controller = BallController(this);
priority = RenderPriority.ballOnBoard; zIndex = ZIndexes.ballOnBoard;
} }
/// [Ball] used in [DebugPinballGame]. /// [Ball] used in [DebugPinballGame].
ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) { ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) {
controller = DebugBallController(this); controller = BallController(this);
priority = RenderPriority.ballOnBoard; zIndex = ZIndexes.ballOnBoard;
} }
} }
@ -49,10 +48,8 @@ class BallController extends ComponentController<Ball>
/// {@macro ball_controller} /// {@macro ball_controller}
BallController(Ball ball) : super(ball); BallController(Ball ball) : super(ball);
/// Removes the [Ball] from a [PinballGame]. /// Event triggered when the ball is lost.
/// // TODO(alestiago): Refactor using behaviors.
/// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into
/// a [BottomWall].
void lost() { void lost() {
component.shouldRemove = true; component.shouldRemove = true;
} }
@ -76,15 +73,9 @@ class BallController extends ComponentController<Ball>
@override @override
void onRemove() { void onRemove() {
super.onRemove(); super.onRemove();
gameRef.read<GameBloc>().add(const BallLost()); final noBallsLeft = gameRef.descendants().whereType<Ball>().isEmpty;
if (noBallsLeft) {
gameRef.read<GameBloc>().add(const RoundLost());
} }
} }
/// {@macro ball_controller}
class DebugBallController extends BallController {
/// {@macro ball_controller}
DebugBallController(Ball<Forge2DGame> component) : super(component);
@override
void onRemove() {}
} }

@ -0,0 +1,21 @@
import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template dino_desert}
/// Area located next to the [Launcher] containing the [ChromeDino] and
/// [DinoWalls].
/// {@endtemplate}
// TODO(allisonryan0002): use a controller to initiate dino bonus when dino is
// fully implemented.
class DinoDesert extends Component {
/// {@macro dino_desert}
DinoDesert()
: super(
children: [
ChromeDino()..initialPosition = Vector2(12.3, -6.9),
DinoWalls(),
Slingshots(),
],
);
}

@ -0,0 +1,34 @@
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template drain}
/// Area located at the bottom of the board to detect when a [Ball] is lost.
/// {@endtemplate}
// TODO(allisonryan0002): move to components package when possible.
class Drain extends BodyComponent with ContactCallbacks {
/// {@macro drain}
Drain() : super(renderBody: false);
@override
Body createBody() {
final shape = EdgeShape()
..set(
BoardDimensions.bounds.bottomLeft.toVector2(),
BoardDimensions.bounds.bottomRight.toVector2(),
);
final fixtureDef = FixtureDef(shape, isSensor: true);
final bodyDef = BodyDef(userData: this);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
// TODO(allisonryan0002): move this to ball.dart when BallLost is removed.
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! ControlledBall) return;
other.controller.lost();
}
}

@ -1,102 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template flutter_forest}
/// Area positioned at the top right of the [Board] where the [Ball]
/// can bounce off [DashNestBumper]s.
///
/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest]
/// is awarded, and the [DashNestBumper.main] releases a new [Ball].
/// {@endtemplate}
class FlutterForest extends Component
with Controls<_FlutterForestController>, HasGameRef<PinballGame> {
/// {@macro flutter_forest}
FlutterForest() {
controller = _FlutterForestController(this);
}
@override
Future<void> onLoad() async {
await super.onLoad();
gameRef.addContactCallback(_DashNestBumperBallContactCallback());
final signpost = Signpost()..initialPosition = Vector2(8.35, -58.3);
final bigNest = _DashNestBumper.main()
..initialPosition = Vector2(18.55, -59.35);
final smallLeftNest = _DashNestBumper.a()
..initialPosition = Vector2(8.95, -51.95);
final smallRightNest = _DashNestBumper.b()
..initialPosition = Vector2(23.3, -46.75);
final dashAnimatronic = DashAnimatronic()..position = Vector2(20, -66);
await addAll([
signpost,
smallLeftNest,
smallRightNest,
bigNest,
dashAnimatronic,
]);
}
}
class _FlutterForestController extends ComponentController<FlutterForest>
with HasGameRef<PinballGame> {
_FlutterForestController(FlutterForest flutterForest) : super(flutterForest);
final _activatedBumpers = <DashNestBumper>{};
void activateBumper(DashNestBumper dashNestBumper) {
if (!_activatedBumpers.add(dashNestBumper)) return;
dashNestBumper.activate();
final activatedBonus = _activatedBumpers.length == 3;
if (activatedBonus) {
_addBonusBall();
gameRef.read<GameBloc>().add(const BonusActivated(GameBonus.dashNest));
_activatedBumpers
..forEach((bumper) => bumper.deactivate())
..clear();
component.firstChild<DashAnimatronic>()?.playing = true;
}
}
Future<void> _addBonusBall() async {
await gameRef.add(
ControlledBall.bonus(theme: gameRef.theme)
..initialPosition = Vector2(17.2, -52.7),
);
}
}
// TODO(alestiago): Revisit ScorePoints logic once the FlameForge2D
// ContactCallback process is enhanced.
class _DashNestBumper extends DashNestBumper with ScorePoints {
_DashNestBumper.main() : super.main();
_DashNestBumper.a() : super.a();
_DashNestBumper.b() : super.b();
@override
int get points => 20;
}
class _DashNestBumperBallContactCallback
extends ContactCallback<DashNestBumper, Ball> {
@override
void begin(DashNestBumper dashNestBumper, _, __) {
final parent = dashNestBumper.parent;
if (parent is FlutterForest) {
parent.controller.activateBumper(dashNestBumper);
}
}
}

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

@ -0,0 +1,41 @@
import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest]
/// is awarded, and the [DashNestBumper.main] releases a new [Ball].
class FlutterForestBonusBehavior extends Component
with ParentIsA<FlutterForest>, HasGameRef<PinballGame> {
@override
void onMount() {
super.onMount();
final bumpers = parent.children.whereType<DashNestBumper>();
for (final bumper in bumpers) {
// TODO(alestiago): Refactor subscription management once the following is
// merged:
// https://github.com/flame-engine/flame/pull/1538
bumper.bloc.stream.listen((state) {
final achievedBonus = bumpers.every(
(bumper) => bumper.bloc.state == DashNestBumperState.active,
);
if (achievedBonus) {
gameRef
.read<GameBloc>()
.add(const BonusActivated(GameBonus.dashNest));
gameRef.add(
ControlledBall.bonus(characterTheme: gameRef.characterTheme)
..initialPosition = Vector2(17.2, -52.7),
);
parent.firstChild<DashAnimatronic>()?.playing = true;
for (final bumper in bumpers) {
bumper.bloc.onReset();
}
}
});
}
}
}

@ -0,0 +1,51 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template flutter_forest}
/// Area positioned at the top right of the board where the [Ball] can bounce
/// off [DashNestBumper]s.
/// {@endtemplate}
class FlutterForest extends Component with ZIndex {
/// {@macro flutter_forest}
FlutterForest()
: super(
children: [
Signpost(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(8.35, -58.3),
DashNestBumper.main(
children: [
ScoringBehavior(points: 200000),
],
)..initialPosition = Vector2(18.55, -59.35),
DashNestBumper.a(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(8.95, -51.95),
DashNestBumper.b(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(23.3, -46.75),
DashAnimatronic()..position = Vector2(20, -66),
FlutterForestBonusBehavior(),
],
) {
zIndex = ZIndexes.flutterForest;
}
/// Creates a [FlutterForest] without any children.
///
/// This can be used for testing [FlutterForest]'s behaviors in isolation.
@visibleForTesting
FlutterForest.test();
}

@ -32,8 +32,7 @@ class GameFlowController extends ComponentController<PinballGame>
// next page // next page
component.firstChild<Backboard>()?.gameOverMode( component.firstChild<Backboard>()?.gameOverMode(
score: state?.score ?? 0, score: state?.score ?? 0,
characterIconPath: characterIconPath: component.characterTheme.leaderboardIcon.keyName,
component.theme.characterTheme.leaderboardIcon.keyName,
); );
component.firstChild<CameraController>()?.focusOnBackboard(); component.firstChild<CameraController>()?.focusOnBackboard();
} }

@ -1,101 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template google_word}
/// Loads all [GoogleLetter]s to compose a [GoogleWord].
/// {@endtemplate}
class GoogleWord extends Component
with HasGameRef<PinballGame>, Controls<_GoogleWordController> {
/// {@macro google_word}
GoogleWord({
required Vector2 position,
}) : _position = position {
controller = _GoogleWordController(this);
}
final Vector2 _position;
@override
Future<void> onLoad() async {
await super.onLoad();
gameRef.addContactCallback(_GoogleLetterBallContactCallback());
final offsets = [
Vector2(-12.92, 1.82),
Vector2(-8.33, -0.65),
Vector2(-2.88, -1.75),
Vector2(2.88, -1.75),
Vector2(8.33, -0.65),
Vector2(12.92, 1.82),
];
final letters = <GoogleLetter>[];
for (var index = 0; index < offsets.length; index++) {
letters.add(
GoogleLetter(index)..initialPosition = _position + offsets[index],
);
}
await addAll(letters);
}
}
class _GoogleWordController extends ComponentController<GoogleWord>
with HasGameRef<PinballGame> {
_GoogleWordController(GoogleWord googleWord) : super(googleWord);
final _activatedLetters = <GoogleLetter>{};
Future<void> activate(GoogleLetter googleLetter) async {
if (!_activatedLetters.add(googleLetter)) return;
googleLetter.activate();
final activatedBonus = _activatedLetters.length == 6;
if (activatedBonus) {
gameRef.audio.googleBonus();
gameRef.read<GameBloc>().add(const BonusActivated(GameBonus.googleWord));
await _bonusAnimation();
_activatedLetters.clear();
}
}
Future<void> _bonusAnimation() async {
const blinkDuration = Duration(milliseconds: 300);
const blinkCount = 4;
final googleLetters = component.children.whereType<GoogleLetter>();
var shouldActivate = false;
await Future<void>.delayed(blinkDuration);
for (var i = 1; i < blinkCount * 2; i++) {
for (final letter in googleLetters) {
if (shouldActivate) {
letter.activate();
} else {
letter.deactivate();
}
}
shouldActivate = !shouldActivate;
await Future<void>.delayed(blinkDuration);
}
}
}
/// Activates a [GoogleLetter] when it contacts with a [Ball].
class _GoogleLetterBallContactCallback
extends ContactCallback<GoogleLetter, Ball> {
@override
void begin(GoogleLetter googleLetter, _, __) {
final parent = googleLetter.parent;
if (parent is GoogleWord) {
parent.controller.activate(googleLetter);
}
}
}

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

@ -0,0 +1,34 @@
import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// Adds a [GameBonus.googleWord] when all [GoogleLetter]s are activated.
class GoogleWordBonusBehavior extends Component
with HasGameRef<PinballGame>, ParentIsA<GoogleWord> {
@override
void onMount() {
super.onMount();
final googleLetters = parent.children.whereType<GoogleLetter>();
for (final letter in googleLetters) {
// TODO(alestiago): Refactor subscription management once the following is
// merged:
// https://github.com/flame-engine/flame/pull/1538
letter.bloc.stream.listen((_) {
final achievedBonus = googleLetters
.every((letter) => letter.bloc.state == GoogleLetterState.active);
if (achievedBonus) {
gameRef.audio.googleBonus();
gameRef
.read<GameBloc>()
.add(const BonusActivated(GameBonus.googleWord));
for (final letter in googleLetters) {
letter.bloc.onReset();
}
}
});
}
}
}

@ -0,0 +1,52 @@
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/components/google_word/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template google_word}
/// Loads all [GoogleLetter]s to compose a [GoogleWord].
/// {@endtemplate}
class GoogleWord extends Component with ZIndex {
/// {@macro google_word}
GoogleWord({
required Vector2 position,
}) : super(
children: [
GoogleLetter(
0,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(-12.92, 1.82),
GoogleLetter(
1,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(-8.33, -0.65),
GoogleLetter(
2,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(-2.88, -1.75),
GoogleLetter(
3,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(2.88, -1.75),
GoogleLetter(
4,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(8.33, -0.65),
GoogleLetter(
5,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(12.92, 1.82),
GoogleWordBonusBehavior(),
],
) {
zIndex = ZIndexes.decal;
}
/// Creates a [GoogleWord] without any children.
///
/// This can be used for testing [GoogleWord]'s behaviors in isolation.
@visibleForTesting
GoogleWord.test();
}

@ -1,21 +1,20 @@
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame/components.dart';
import 'package:pinball/game/components/components.dart'; import 'package:pinball/game/components/components.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_components/pinball_components.dart' hide Assets;
import 'package:pinball_flame/pinball_flame.dart';
/// {@template launcher} /// {@template launcher}
/// A [Blueprint] which creates the [Plunger], [RocketSpriteComponent] and /// Channel on the right side of the board containing the [LaunchRamp],
/// [LaunchRamp]. /// [Plunger], and [RocketSpriteComponent].
/// {@endtemplate} /// {@endtemplate}
class Launcher extends Blueprint { class Launcher extends Component {
/// {@macro launcher} /// {@macro launcher}
Launcher() Launcher()
: super( : super(
components: [ children: [
ControlledPlunger(compressionDistance: 14) LaunchRamp(),
..initialPosition = Vector2(40.7, 38), ControlledPlunger(compressionDistance: 10.5)
RocketSpriteComponent()..position = Vector2(43, 62), ..initialPosition = Vector2(41.1, 43),
RocketSpriteComponent()..position = Vector2(43, 62.3),
], ],
blueprints: [LaunchRamp()],
); );
} }

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

@ -0,0 +1,25 @@
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';
/// Toggle each [Multiplier] when GameState.multiplier changes.
class MultipliersBehavior extends Component
with
HasGameRef<PinballGame>,
ParentIsA<Multipliers>,
BlocComponent<GameBloc, GameState> {
@override
bool listenWhen(GameState? previousState, GameState newState) {
return previousState?.multiplier != newState.multiplier;
}
@override
void onNewState(GameState state) {
final multipliers = parent.children.whereType<Multiplier>();
for (final multiplier in multipliers) {
multiplier.bloc.next(state.multiplier);
}
}
}

@ -0,0 +1,47 @@
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/components/multipliers/behaviors/behaviors.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template multipliers}
/// A group for the multipliers on the board.
/// {@endtemplate}
class Multipliers extends Component with ZIndex {
/// {@macro multipliers}
Multipliers()
: super(
children: [
Multiplier.x2(
position: Vector2(-19.5, -2),
angle: -15 * math.pi / 180,
),
Multiplier.x3(
position: Vector2(13, -9.4),
angle: 15 * math.pi / 180,
),
Multiplier.x4(
position: Vector2(0, -21.2),
angle: 0,
),
Multiplier.x5(
position: Vector2(-8.5, -28),
angle: -3 * math.pi / 180,
),
Multiplier.x6(
position: Vector2(10, -30.7),
angle: 8 * math.pi / 180,
),
MultipliersBehavior(),
],
) {
zIndex = ZIndexes.decal;
}
/// Creates [Multipliers] without any children.
///
/// This can be used for testing [Multipliers]'s behaviors in isolation.
@visibleForTesting
Multipliers.test();
}

@ -1,47 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template score_points}
/// Specifies the amount of points received on [Ball] collision.
/// {@endtemplate}
mixin ScorePoints<T extends Forge2DGame> on BodyComponent<T> {
/// {@macro score_points}
int get points;
@override
Future<void> onLoad() async {
await super.onLoad();
body.userData = this;
}
}
/// {@template ball_score_points_callbacks}
/// Adds points to the score when a [Ball] collides with a [BodyComponent] that
/// implements [ScorePoints].
/// {@endtemplate}
class BallScorePointsCallback extends ContactCallback<Ball, ScorePoints> {
/// {@macro ball_score_points_callbacks}
BallScorePointsCallback(PinballGame game) : _gameRef = game;
final PinballGame _gameRef;
@override
void begin(
Ball ball,
ScorePoints scorePoints,
Contact _,
) {
_gameRef.read<GameBloc>().add(Scored(points: scorePoints.points));
_gameRef.audio.score();
_gameRef.add(
ScoreText(
text: scorePoints.points.toString(),
position: ball.body.position,
),
);
}
}

@ -0,0 +1,34 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template scoring_behavior}
/// Adds points to the score when the ball contacts the [parent].
/// {@endtemplate}
class ScoringBehavior extends ContactBehavior with HasGameRef<PinballGame> {
/// {@macro scoring_behavior}
ScoringBehavior({
required int points,
}) : _points = points;
final int _points;
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is! Ball) return;
gameRef.read<GameBloc>().add(Scored(points: _points));
gameRef.audio.score();
gameRef.firstChild<ZCanvasComponent>()!.add(
ScoreText(
text: _points.toString(),
position: other.body.position,
),
);
}
}

@ -1,110 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template sparky_fire_zone}
/// Area positioned at the top left of the [Board] where the [Ball]
/// can bounce off [SparkyBumper]s.
///
/// When a [Ball] hits [SparkyBumper]s, the bumper animates.
/// {@endtemplate}
class SparkyFireZone extends Blueprint {
/// {@macro sparky_fire_zone}
SparkyFireZone()
: super(
components: [
_SparkyBumper.a()..initialPosition = Vector2(-22.9, -41.65),
_SparkyBumper.b()..initialPosition = Vector2(-21.25, -57.9),
_SparkyBumper.c()..initialPosition = Vector2(-3.3, -52.55),
SparkyComputerSensor()..initialPosition = Vector2(-13, -49.8),
SparkyAnimatronic()..position = Vector2(-13.8, -58.2),
],
blueprints: [
SparkyComputer(),
],
);
}
// TODO(alestiago): Revisit ScorePoints logic once the FlameForge2D
// ContactCallback process is enhanced.
class _SparkyBumper extends SparkyBumper with ScorePoints {
_SparkyBumper.a() : super.a();
_SparkyBumper.b() : super.b();
_SparkyBumper.c() : super.c();
@override
int get points => 20;
@override
Future<void> onLoad() async {
await super.onLoad();
// TODO(alestiago): Revisit once this has been merged:
// https://github.com/flame-engine/flame/pull/1547
gameRef.addContactCallback(SparkyBumperBallContactCallback());
}
}
/// Listens when a [Ball] bounces bounces against a [SparkyBumper].
@visibleForTesting
class SparkyBumperBallContactCallback
extends ContactCallback<SparkyBumper, Ball> {
@override
void begin(
SparkyBumper sparkyBumper,
Ball _,
Contact __,
) {
sparkyBumper.animate();
}
}
/// {@template sparky_computer_sensor}
/// Small sensor body used to detect when a ball has entered the
/// [SparkyComputer].
/// {@endtemplate}
// TODO(alestiago): Revisit once this has been merged:
// https://github.com/flame-engine/flame/pull/1547
class SparkyComputerSensor extends BodyComponent with InitialPosition {
/// {@macro sparky_computer_sensor}
SparkyComputerSensor() {
renderBody = false;
}
@override
Body createBody() {
final shape = CircleShape()..radius = 0.1;
final fixtureDef = FixtureDef(shape, isSensor: true);
final bodyDef = BodyDef(
position: initialPosition,
userData: this,
);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
@override
Future<void> onLoad() async {
await super.onLoad();
// TODO(alestiago): Revisit once this has been merged:
// https://github.com/flame-engine/flame/pull/1547
gameRef.addContactCallback(SparkyComputerSensorBallContactCallback());
}
}
@visibleForTesting
// TODO(alestiago): Revisit once this has been merged:
// https://github.com/flame-engine/flame/pull/1547
// ignore: public_member_api_docs
class SparkyComputerSensorBallContactCallback
extends ContactCallback<SparkyComputerSensor, ControlledBall> {
@override
void begin(_, ControlledBall controlledBall, __) {
controlledBall.controller.turboCharge();
controlledBall.gameRef.firstChild<SparkyAnimatronic>()?.playing = true;
}
}

@ -0,0 +1,73 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template sparky_scorch}
/// Area positioned at the top left of the board containing the
/// [SparkyComputer], [SparkyAnimatronic], and [SparkyBumper]s.
/// {@endtemplate}
class SparkyScorch extends Component {
/// {@macro sparky_scorch}
SparkyScorch()
: super(
children: [
SparkyBumper.a(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-22.9, -41.65),
SparkyBumper.b(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-21.25, -57.9),
SparkyBumper.c(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-3.3, -52.55),
SparkyComputerSensor()..initialPosition = Vector2(-13, -49.8),
SparkyAnimatronic()..position = Vector2(-13.8, -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: [
ScoringBehavior(points: 200000),
],
);
@override
Body createBody() {
final shape = CircleShape()..radius = 0.1;
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;
}
}

@ -1,66 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets;
/// {@template wall}
/// A continuous generic and [BodyType.static] barrier that divides a game area.
/// {@endtemplate}
// TODO(alestiago): Remove [Wall] for [Pathway.straight].
class Wall extends BodyComponent {
/// {@macro wall}
Wall({
required this.start,
required this.end,
});
/// The [start] of the [Wall].
final Vector2 start;
/// The [end] of the [Wall].
final Vector2 end;
@override
Body createBody() {
final shape = EdgeShape()..set(start, end);
final fixtureDef = FixtureDef(shape)
..restitution = 0.1
..friction = 0;
final bodyDef = BodyDef()
..userData = this
..position = Vector2.zero()
..type = BodyType.static;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
/// {@template bottom_wall}
/// [Wall] located at the bottom of the board.
///
/// Collisions with [BottomWall] are listened by
/// [BottomWallBallContactCallback].
/// {@endtemplate}
class BottomWall extends Wall {
/// {@macro bottom_wall}
BottomWall()
: super(
start: BoardDimensions.bounds.bottomLeft.toVector2(),
end: BoardDimensions.bounds.bottomRight.toVector2(),
);
}
/// {@template bottom_wall_ball_contact_callback}
/// Listens when a [ControlledBall] falls into a [BottomWall].
/// {@endtemplate}
class BottomWallBallContactCallback
extends ContactCallback<ControlledBall, BottomWall> {
@override
void begin(ControlledBall ball, BottomWall wall, Contact contact) {
ball.controller.lost();
}
}

@ -13,6 +13,7 @@ extension PinballGameAssetsX on PinballGame {
const dinoTheme = DinoTheme(); const dinoTheme = DinoTheme();
return [ return [
images.load(components.Assets.images.boardBackground.keyName),
images.load(components.Assets.images.ball.ball.keyName), images.load(components.Assets.images.ball.ball.keyName),
images.load(components.Assets.images.ball.flameEffect.keyName), images.load(components.Assets.images.ball.flameEffect.keyName),
images.load(components.Assets.images.signpost.inactive.keyName), images.load(components.Assets.images.signpost.inactive.keyName),
@ -23,8 +24,10 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.flipper.right.keyName), images.load(components.Assets.images.flipper.right.keyName),
images.load(components.Assets.images.baseboard.left.keyName), images.load(components.Assets.images.baseboard.left.keyName),
images.load(components.Assets.images.baseboard.right.keyName), images.load(components.Assets.images.baseboard.right.keyName),
images.load(components.Assets.images.kicker.left.keyName), images.load(components.Assets.images.kicker.left.lit.keyName),
images.load(components.Assets.images.kicker.right.keyName), images.load(components.Assets.images.kicker.left.dimmed.keyName),
images.load(components.Assets.images.kicker.right.lit.keyName),
images.load(components.Assets.images.kicker.right.dimmed.keyName),
images.load(components.Assets.images.slingshot.upper.keyName), images.load(components.Assets.images.slingshot.upper.keyName),
images.load(components.Assets.images.slingshot.lower.keyName), images.load(components.Assets.images.slingshot.lower.keyName),
images.load(components.Assets.images.launchRamp.ramp.keyName), images.load(components.Assets.images.launchRamp.ramp.keyName),
@ -34,8 +37,10 @@ extension PinballGameAssetsX on PinballGame {
images.load( images.load(
components.Assets.images.launchRamp.backgroundRailing.keyName, components.Assets.images.launchRamp.backgroundRailing.keyName,
), ),
images.load(components.Assets.images.dino.dinoLandTop.keyName), images.load(components.Assets.images.dino.bottomWall.keyName),
images.load(components.Assets.images.dino.dinoLandBottom.keyName), images.load(components.Assets.images.dino.topWall.keyName),
images.load(components.Assets.images.dino.animatronic.head.keyName),
images.load(components.Assets.images.dino.animatronic.mouth.keyName),
images.load(components.Assets.images.dash.animatronic.keyName), images.load(components.Assets.images.dash.animatronic.keyName),
images.load(components.Assets.images.dash.bumper.a.active.keyName), images.load(components.Assets.images.dash.bumper.a.active.keyName),
images.load(components.Assets.images.dash.bumper.a.inactive.keyName), images.load(components.Assets.images.dash.bumper.a.inactive.keyName),
@ -48,50 +53,52 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.boundary.bottom.keyName), images.load(components.Assets.images.boundary.bottom.keyName),
images.load(components.Assets.images.boundary.outer.keyName), images.load(components.Assets.images.boundary.outer.keyName),
images.load(components.Assets.images.boundary.outerBottom.keyName), images.load(components.Assets.images.boundary.outerBottom.keyName),
images.load(components.Assets.images.spaceship.saucer.keyName), images.load(components.Assets.images.android.spaceship.saucer.keyName),
images.load(components.Assets.images.spaceship.bridge.keyName), images
images.load(components.Assets.images.spaceship.ramp.boardOpening.keyName), .load(components.Assets.images.android.spaceship.animatronic.keyName),
images.load(components.Assets.images.android.spaceship.lightBeam.keyName),
images.load(components.Assets.images.android.ramp.boardOpening.keyName),
images.load( images.load(
components.Assets.images.spaceship.ramp.railingForeground.keyName, components.Assets.images.android.ramp.railingForeground.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.railingBackground.keyName, components.Assets.images.android.ramp.railingBackground.keyName,
), ),
images.load(components.Assets.images.spaceship.ramp.main.keyName), images.load(components.Assets.images.android.ramp.main.keyName),
images images.load(components.Assets.images.android.ramp.arrow.inactive.keyName),
.load(components.Assets.images.spaceship.ramp.arrow.inactive.keyName),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active1.keyName, components.Assets.images.android.ramp.arrow.active1.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active2.keyName, components.Assets.images.android.ramp.arrow.active2.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active3.keyName, components.Assets.images.android.ramp.arrow.active3.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active4.keyName, components.Assets.images.android.ramp.arrow.active4.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active5.keyName, components.Assets.images.android.ramp.arrow.active5.keyName,
), ),
images.load(components.Assets.images.spaceship.rail.main.keyName), images.load(components.Assets.images.android.rail.main.keyName),
images.load(components.Assets.images.spaceship.rail.foreground.keyName), images.load(components.Assets.images.android.rail.exit.keyName),
images.load(components.Assets.images.alienBumper.a.active.keyName), images.load(components.Assets.images.android.bumper.a.lit.keyName),
images.load(components.Assets.images.alienBumper.a.inactive.keyName), images.load(components.Assets.images.android.bumper.a.dimmed.keyName),
images.load(components.Assets.images.alienBumper.b.active.keyName), images.load(components.Assets.images.android.bumper.b.lit.keyName),
images.load(components.Assets.images.alienBumper.b.inactive.keyName), images.load(components.Assets.images.android.bumper.b.dimmed.keyName),
images.load(components.Assets.images.chromeDino.mouth.keyName), images.load(components.Assets.images.android.bumper.cow.lit.keyName),
images.load(components.Assets.images.chromeDino.head.keyName), images.load(components.Assets.images.android.bumper.cow.dimmed.keyName),
images.load(components.Assets.images.sparky.computer.top.keyName), images.load(components.Assets.images.sparky.computer.top.keyName),
images.load(components.Assets.images.sparky.computer.base.keyName), images.load(components.Assets.images.sparky.computer.base.keyName),
images.load(components.Assets.images.sparky.computer.glow.keyName),
images.load(components.Assets.images.sparky.animatronic.keyName), images.load(components.Assets.images.sparky.animatronic.keyName),
images.load(components.Assets.images.sparky.bumper.a.inactive.keyName), images.load(components.Assets.images.sparky.bumper.a.lit.keyName),
images.load(components.Assets.images.sparky.bumper.a.active.keyName), images.load(components.Assets.images.sparky.bumper.a.dimmed.keyName),
images.load(components.Assets.images.sparky.bumper.b.active.keyName), images.load(components.Assets.images.sparky.bumper.b.lit.keyName),
images.load(components.Assets.images.sparky.bumper.b.inactive.keyName), images.load(components.Assets.images.sparky.bumper.b.dimmed.keyName),
images.load(components.Assets.images.sparky.bumper.c.active.keyName), images.load(components.Assets.images.sparky.bumper.c.lit.keyName),
images.load(components.Assets.images.sparky.bumper.c.inactive.keyName), images.load(components.Assets.images.sparky.bumper.c.dimmed.keyName),
images.load(components.Assets.images.backboard.backboardScores.keyName), images.load(components.Assets.images.backboard.backboardScores.keyName),
images.load(components.Assets.images.backboard.backboardGameOver.keyName), images.load(components.Assets.images.backboard.backboardGameOver.keyName),
images.load(components.Assets.images.googleWord.letter1.active.keyName), images.load(components.Assets.images.googleWord.letter1.active.keyName),
@ -107,6 +114,16 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.googleWord.letter6.active.keyName), images.load(components.Assets.images.googleWord.letter6.active.keyName),
images.load(components.Assets.images.googleWord.letter6.inactive.keyName), images.load(components.Assets.images.googleWord.letter6.inactive.keyName),
images.load(components.Assets.images.backboard.display.keyName), images.load(components.Assets.images.backboard.display.keyName),
images.load(components.Assets.images.multiplier.x2.lit.keyName),
images.load(components.Assets.images.multiplier.x2.dimmed.keyName),
images.load(components.Assets.images.multiplier.x3.lit.keyName),
images.load(components.Assets.images.multiplier.x3.dimmed.keyName),
images.load(components.Assets.images.multiplier.x4.lit.keyName),
images.load(components.Assets.images.multiplier.x4.dimmed.keyName),
images.load(components.Assets.images.multiplier.x5.lit.keyName),
images.load(components.Assets.images.multiplier.x5.dimmed.keyName),
images.load(components.Assets.images.multiplier.x6.lit.keyName),
images.load(components.Assets.images.multiplier.x6.dimmed.keyName),
images.load(dashTheme.leaderboardIcon.keyName), images.load(dashTheme.leaderboardIcon.keyName),
images.load(sparkyTheme.leaderboardIcon.keyName), images.load(sparkyTheme.leaderboardIcon.keyName),
images.load(androidTheme.leaderboardIcon.keyName), images.load(androidTheme.leaderboardIcon.keyName),

@ -6,21 +6,22 @@ import 'package:flame/game.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart';
class PinballGame extends Forge2DGame class PinballGame extends Forge2DGame
with with
FlameBloc, FlameBloc,
HasKeyboardHandlerComponents, HasKeyboardHandlerComponents,
Controls<_GameBallsController> { Controls<_GameBallsController>,
TapDetector {
PinballGame({ PinballGame({
required this.theme, required this.characterTheme,
required this.audio, required this.audio,
}) { }) {
images.prefix = ''; images.prefix = '';
@ -33,7 +34,7 @@ class PinballGame extends Forge2DGame
@override @override
Color backgroundColor() => Colors.transparent; Color backgroundColor() => Colors.transparent;
final PinballTheme theme; final CharacterTheme characterTheme;
final PinballAudio audio; final PinballAudio audio;
@ -41,162 +42,170 @@ class PinballGame extends Forge2DGame
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
_addContactCallbacks(); await add(gameFlowController = GameFlowController(this));
await add(CameraController(this));
unawaited(add(gameFlowController = GameFlowController(this)));
unawaited(add(CameraController(this))); final machine = [
unawaited(add(Backboard.waiting(position: Vector2(0, -88)))); BoardBackgroundSpriteComponent(),
Boundaries(),
// TODO(allisonryan0002): banish Wall and Board classes in later PR. Backboard.waiting(position: Vector2(0, -88)),
await add(BottomWall()); ];
unawaited(addFromBlueprint(Boundaries())); final decals = [
unawaited(addFromBlueprint(LaunchRamp())); GoogleWord(
position: Vector2(-4.1, 1.8),
final launcher = Launcher();
unawaited(addFromBlueprint(launcher));
unawaited(add(Board()));
unawaited(add(AlienZone()));
await addFromBlueprint(SparkyFireZone());
unawaited(addFromBlueprint(Slingshots()));
unawaited(addFromBlueprint(DinoWalls()));
unawaited(_addBonusWord());
unawaited(addFromBlueprint(SpaceshipRamp()));
unawaited(
addFromBlueprint(
Spaceship(
position: Vector2(-26.5, -28.5),
), ),
Multipliers(),
];
final characterAreas = [
AndroidAcres(),
DinoDesert(),
FlutterForest(),
SparkyScorch(),
];
await add(
ZCanvasComponent(
children: [
...machine,
...decals,
...characterAreas,
Drain(),
BottomGroup(),
Launcher(),
],
), ),
); );
unawaited(addFromBlueprint(SpaceshipRail()));
controller.attachTo(launcher.components.whereType<Plunger>().first);
await super.onLoad(); await super.onLoad();
} }
void _addContactCallbacks() { BoardSide? focusedBoardSide;
addContactCallback(BallScorePointsCallback(this));
addContactCallback(BottomWallBallContactCallback()); @override
void onTapDown(TapDownInfo info) {
if (info.raw.kind == PointerDeviceKind.touch) {
final rocket = descendants().whereType<RocketSpriteComponent>().first;
final bounds = rocket.topLeftPosition & rocket.size;
// NOTE(wolfen): As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 we need to check it at the highest level manually.
if (bounds.contains(info.eventPosition.game.toOffset())) {
descendants().whereType<Plunger>().single.pull();
} else {
final leftSide = info.eventPosition.widget.x < canvasSize.x / 2;
focusedBoardSide = leftSide ? BoardSide.left : BoardSide.right;
final flippers = descendants().whereType<Flipper>().where((flipper) {
return flipper.side == focusedBoardSide;
});
flippers.first.moveUp();
}
} }
Future<void> _addBonusWord() async { super.onTapDown(info);
await add( }
GoogleWord(
position: Vector2( @override
BoardDimensions.bounds.center.dx - 4.1, void onTapUp(TapUpInfo info) {
BoardDimensions.bounds.center.dy + 1.8, final rocket = descendants().whereType<RocketSpriteComponent>().first;
), final bounds = rocket.topLeftPosition & rocket.size;
),
); if (bounds.contains(info.eventPosition.game.toOffset())) {
descendants().whereType<Plunger>().single.release();
} else {
_moveFlippersDown();
}
super.onTapUp(info);
}
@override
void onTapCancel() {
descendants().whereType<Plunger>().single.release();
_moveFlippersDown();
super.onTapCancel();
}
void _moveFlippersDown() {
if (focusedBoardSide != null) {
final flippers = descendants().whereType<Flipper>().where((flipper) {
return flipper.side == focusedBoardSide;
});
flippers.first.moveDown();
focusedBoardSide = null;
}
} }
} }
class _GameBallsController extends ComponentController<PinballGame> class _GameBallsController extends ComponentController<PinballGame>
with BlocComponent<GameBloc, GameState>, HasGameRef<PinballGame> { with BlocComponent<GameBloc, GameState> {
_GameBallsController(PinballGame game) : super(game); _GameBallsController(PinballGame game) : super(game);
late final Plunger _plunger;
@override @override
bool listenWhen(GameState? previousState, GameState newState) { bool listenWhen(GameState? previousState, GameState newState) {
final noBallsLeft = component.descendants().whereType<Ball>().isEmpty; final noBallsLeft = component.descendants().whereType<Ball>().isEmpty;
final canBallRespawn = newState.balls > 0; final notGameOver = !newState.isGameOver;
return noBallsLeft && canBallRespawn; return noBallsLeft && notGameOver;
} }
@override @override
void onNewState(GameState state) { void onNewState(GameState state) {
super.onNewState(state); super.onNewState(state);
_spawnBall(); spawnBall();
} }
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
_spawnBall(); spawnBall();
} }
void _spawnBall() { void spawnBall() {
// TODO(alestiago): Refactor with behavioural pattern.
component.ready().whenComplete(() {
final plunger = parent!.descendants().whereType<Plunger>().single;
final ball = ControlledBall.launch( final ball = ControlledBall.launch(
theme: gameRef.theme, characterTheme: component.characterTheme,
)..initialPosition = Vector2( )..initialPosition = Vector2(
_plunger.body.position.x, plunger.body.position.x,
_plunger.body.position.y - Ball.size.y, plunger.body.position.y - Ball.size.y,
); );
component.add(ball); component.firstChild<ZCanvasComponent>()?.add(ball);
} });
/// Attaches the controller to the plunger.
// TODO(alestiago): Remove this method and use onLoad instead.
// ignore: use_setters_to_change_properties
void attachTo(Plunger plunger) {
_plunger = plunger;
} }
} }
class DebugPinballGame extends PinballGame with FPSCounter, TapDetector { class DebugPinballGame extends PinballGame with FPSCounter {
DebugPinballGame({ DebugPinballGame({
required PinballTheme theme, required CharacterTheme characterTheme,
required PinballAudio audio, required PinballAudio audio,
}) : super( }) : super(
theme: theme, characterTheme: characterTheme,
audio: audio, audio: audio,
) { ) {
controller = _DebugGameBallsController(this); controller = _GameBallsController(this);
} }
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
await _loadBackground();
await add(_DebugInformation()); await add(_DebugInformation());
} }
// TODO(alestiago): Move to PinballGame once we have the real background
// component.
Future<void> _loadBackground() async {
final sprite = await loadSprite(
Assets.images.components.background.path,
);
final spriteComponent = SpriteComponent(
sprite: sprite,
size: Vector2(120, 160),
anchor: Anchor.center,
)
..position = Vector2(0, -7.8)
..priority = RenderPriority.background;
await add(spriteComponent);
}
@override @override
void onTapUp(TapUpInfo info) { void onTapUp(TapUpInfo info) {
add( super.onTapUp(info);
ControlledBall.debug()..initialPosition = info.eventPosition.game,
);
}
}
class _DebugGameBallsController extends _GameBallsController {
_DebugGameBallsController(PinballGame game) : super(game);
@override if (info.raw.kind == PointerDeviceKind.mouse) {
bool listenWhen(GameState? previousState, GameState newState) { final ball = ControlledBall.debug()
final noBallsLeft = component ..initialPosition = info.eventPosition.game;
.descendants() firstChild<ZCanvasComponent>()?.add(ball);
.whereType<ControlledBall>() }
.where((ball) => ball.controller is! DebugBallController)
.isEmpty;
final canBallRespawn = newState.balls > 0;
return noBallsLeft && canBallRespawn;
} }
} }
// TODO(wolfenrain): investigate this CI failure.
// coverage:ignore-start
class _DebugInformation extends Component with HasGameRef<DebugPinballGame> { class _DebugInformation extends Component with HasGameRef<DebugPinballGame> {
_DebugInformation() : super(priority: RenderPriority.debugInfo);
@override @override
PositionType get positionType => PositionType.widget; PositionType get positionType => PositionType.widget;
@ -225,3 +234,4 @@ class _DebugInformation extends Component with HasGameRef<DebugPinballGame> {
_debugTextPaint.render(canvas, debugText, position); _debugTextPaint.render(canvas, debugText, position);
} }
} }
// coverage:ignore-end

@ -5,8 +5,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball/start_game/start_game.dart'; import 'package:pinball/start_game/start_game.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
class PinballGamePage extends StatelessWidget { class PinballGamePage extends StatelessWidget {
@ -31,17 +31,19 @@ class PinballGamePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.read<ThemeCubit>().state.theme; final characterTheme =
context.read<CharacterThemeCubit>().state.characterTheme;
final audio = context.read<PinballAudio>(); final audio = context.read<PinballAudio>();
final pinballAudio = context.read<PinballAudio>(); final pinballAudio = context.read<PinballAudio>();
final game = isDebugMode final game = isDebugMode
? DebugPinballGame(theme: theme, audio: audio) ? DebugPinballGame(characterTheme: characterTheme, audio: audio)
: PinballGame(theme: theme, audio: audio); : PinballGame(characterTheme: characterTheme, audio: audio);
final loadables = [ final loadables = [
...game.preLoadAssets(), ...game.preLoadAssets(),
pinballAudio.load(), pinballAudio.load(),
...BonusAnimation.loadAssets(),
]; ];
return MultiBlocProvider( return MultiBlocProvider(
@ -112,6 +114,10 @@ class PinballGameLoadedView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16;
final screenWidth = MediaQuery.of(context).size.width;
final leftMargin = (screenWidth / 2) - (gameWidgetWidth / 1.8);
return Stack( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
@ -130,10 +136,12 @@ class PinballGameLoadedView extends StatelessWidget {
}, },
), ),
), ),
const Positioned( // TODO(arturplaczek): add Visibility to GameHud based on StartGameBloc
top: 8, // status
left: 8, Positioned(
child: GameHud(), top: 16,
left: leftMargin,
child: const GameHud(),
), ),
], ],
); );

@ -1,19 +1,23 @@
// ignore_for_file: public_member_api_docs
import 'package:flame/flame.dart'; import 'package:flame/flame.dart';
import 'package:flame/sprite.dart'; import 'package:flame/sprite.dart';
import 'package:flame/widgets.dart';
import 'package:flutter/material.dart' hide Image; import 'package:flutter/material.dart' hide Image;
import 'package:pinball/gen/assets.gen.dart'; import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_flame/pinball_flame.dart';
class BonusAnimation extends StatelessWidget { /// {@template bonus_animation}
/// [Widget] that displays bonus animations.
/// {@endtemplate}
class BonusAnimation extends StatefulWidget {
/// {@macro bonus_animation}
const BonusAnimation._( const BonusAnimation._(
this.imagePath, { String imagePath, {
VoidCallback? onCompleted, VoidCallback? onCompleted,
Key? key, Key? key,
}) : _onCompleted = onCompleted, }) : _imagePath = imagePath,
_onCompleted = onCompleted,
super(key: key); super(key: key);
/// [Widget] that displays the dash nest animation.
BonusAnimation.dashNest({ BonusAnimation.dashNest({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
@ -23,6 +27,7 @@ class BonusAnimation extends StatelessWidget {
key: key, key: key,
); );
/// [Widget] that displays the sparky turbo charge animation.
BonusAnimation.sparkyTurboCharge({ BonusAnimation.sparkyTurboCharge({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
@ -32,56 +37,94 @@ class BonusAnimation extends StatelessWidget {
key: key, key: key,
); );
BonusAnimation.dino({ /// [Widget] that displays the dino chomp animation.
BonusAnimation.dinoChomp({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
}) : this._( }) : this._(
Assets.images.bonusAnimation.dino.keyName, Assets.images.bonusAnimation.dinoChomp.keyName,
onCompleted: onCompleted, onCompleted: onCompleted,
key: key, key: key,
); );
BonusAnimation.android({ /// [Widget] that displays the android spaceship animation.
BonusAnimation.androidSpaceship({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
}) : this._( }) : this._(
Assets.images.bonusAnimation.android.keyName, Assets.images.bonusAnimation.androidSpaceship.keyName,
onCompleted: onCompleted, onCompleted: onCompleted,
key: key, key: key,
); );
BonusAnimation.google({ /// [Widget] that displays the google word animation.
BonusAnimation.googleWord({
Key? key, Key? key,
VoidCallback? onCompleted, VoidCallback? onCompleted,
}) : this._( }) : this._(
Assets.images.bonusAnimation.google.keyName, Assets.images.bonusAnimation.googleWord.keyName,
onCompleted: onCompleted, onCompleted: onCompleted,
key: key, key: key,
); );
final String imagePath; final String _imagePath;
final VoidCallback? _onCompleted; final VoidCallback? _onCompleted;
static Future<void> loadAssets() { /// Returns a list of assets to be loaded for animations.
static List<Future> loadAssets() {
Flame.images.prefix = ''; Flame.images.prefix = '';
return Flame.images.loadAll([ return [
Assets.images.bonusAnimation.dashNest.keyName, Flame.images.load(Assets.images.bonusAnimation.dashNest.keyName),
Assets.images.bonusAnimation.sparkyTurboCharge.keyName, Flame.images.load(Assets.images.bonusAnimation.sparkyTurboCharge.keyName),
Assets.images.bonusAnimation.dino.keyName, Flame.images.load(Assets.images.bonusAnimation.dinoChomp.keyName),
Assets.images.bonusAnimation.android.keyName, Flame.images.load(Assets.images.bonusAnimation.androidSpaceship.keyName),
Assets.images.bonusAnimation.google.keyName, Flame.images.load(Assets.images.bonusAnimation.googleWord.keyName),
]); ];
}
@override
State<BonusAnimation> createState() => _BonusAnimationState();
}
class _BonusAnimationState extends State<BonusAnimation>
with TickerProviderStateMixin {
late SpriteAnimationController controller;
late SpriteAnimation animation;
bool shouldRunBuildCallback = true;
@override
void dispose() {
controller.dispose();
super.dispose();
}
// When the animation is overwritten by another animation, we need to stop
// the callback in the build method as it will break the new animation.
// Otherwise we need to set up a new callback when a new animation starts to
// show the score view at the end of the animation.
@override
void didUpdateWidget(BonusAnimation oldWidget) {
shouldRunBuildCallback = oldWidget._imagePath == widget._imagePath;
Future<void>.delayed(
Duration(seconds: animation.totalDuration().ceil()),
() {
widget._onCompleted?.call();
},
);
super.didUpdateWidget(oldWidget);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spriteSheet = SpriteSheet.fromColumnsAndRows( final spriteSheet = SpriteSheet.fromColumnsAndRows(
image: Flame.images.fromCache(imagePath), image: Flame.images.fromCache(widget._imagePath),
columns: 8, columns: 8,
rows: 9, rows: 9,
); );
final animation = spriteSheet.createAnimation( animation = spriteSheet.createAnimation(
row: 0, row: 0,
stepTime: 1 / 24, stepTime: 1 / 24,
to: spriteSheet.rows * spriteSheet.columns, to: spriteSheet.rows * spriteSheet.columns,
@ -91,15 +134,22 @@ class BonusAnimation extends StatelessWidget {
Future<void>.delayed( Future<void>.delayed(
Duration(seconds: animation.totalDuration().ceil()), Duration(seconds: animation.totalDuration().ceil()),
() { () {
_onCompleted?.call(); if (shouldRunBuildCallback) {
widget._onCompleted?.call();
}
}, },
); );
controller = SpriteAnimationController(
animation: animation,
vsync: this,
)..forward();
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
child: SpriteAnimationWidget( child: SpriteAnimationWidget(
animation: animation, controller: controller,
), ),
); );
} }

@ -1,46 +1,122 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/gen.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// {@template game_hud} /// {@template game_hud}
/// Overlay of a [PinballGame] that displays the current [GameState.score] and /// Overlay on the [PinballGame].
/// [GameState.balls]. ///
/// Displays the current [GameState.score], [GameState.rounds] and animates when
/// the player gets a [GameBonus].
/// {@endtemplate} /// {@endtemplate}
class GameHud extends StatelessWidget { class GameHud extends StatefulWidget {
/// {@macro game_hud} /// {@macro game_hud}
const GameHud({Key? key}) : super(key: key); const GameHud({Key? key}) : super(key: key);
@override
State<GameHud> createState() => _GameHudState();
}
class _GameHudState extends State<GameHud> {
bool showAnimation = false;
/// Ratio from sprite frame (width 500, height 144) w / h = ratio
static const _ratio = 3.47;
static const _width = 265.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = context.watch<GameBloc>().state; final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver);
return Container( return _ScoreViewDecoration(
color: Colors.redAccent, child: SizedBox(
width: 200, height: _width / _ratio,
height: 100, width: _width,
padding: const EdgeInsets.all(16), child: BlocListener<GameBloc, GameState>(
child: Row( listenWhen: (previous, current) =>
mainAxisAlignment: MainAxisAlignment.spaceBetween, previous.bonusHistory.length != current.bonusHistory.length,
children: [ listener: (_, __) => setState(() => showAnimation = true),
Text( child: AnimatedSwitcher(
'${state.score}', duration: kThemeAnimationDuration,
style: Theme.of(context).textTheme.headline3, child: showAnimation && !isGameOver
), ? _AnimationView(
Wrap( onComplete: () {
direction: Axis.vertical, if (mounted) {
children: [ setState(() => showAnimation = false);
for (var i = 0; i < state.balls; i++) }
const Padding( },
padding: EdgeInsets.only(top: 6, right: 6), )
child: CircleAvatar( : const ScoreView(),
radius: 8, ),
backgroundColor: Colors.black, ),
),
),
],
),
],
), ),
); );
} }
} }
class _ScoreViewDecoration extends StatelessWidget {
const _ScoreViewDecoration({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
const radius = BorderRadius.all(Radius.circular(12));
const borderWidth = 5.0;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(
color: PinballColors.white,
width: borderWidth,
),
image: DecorationImage(
fit: BoxFit.cover,
image: AssetImage(
Assets.images.score.miniScoreBackground.path,
),
),
),
child: Padding(
padding: const EdgeInsets.all(borderWidth - 1),
child: ClipRRect(
borderRadius: radius,
child: child,
),
),
);
}
}
class _AnimationView extends StatelessWidget {
const _AnimationView({
Key? key,
required this.onComplete,
}) : super(key: key);
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
final lastBonus = context.select(
(GameBloc bloc) => bloc.state.bonusHistory.last,
);
switch (lastBonus) {
case GameBonus.dashNest:
return BonusAnimation.dashNest(onCompleted: onComplete);
case GameBonus.sparkyTurboCharge:
return BonusAnimation.sparkyTurboCharge(onCompleted: onComplete);
case GameBonus.dinoChomp:
return BonusAnimation.dinoChomp(onCompleted: onComplete);
case GameBonus.googleWord:
return BonusAnimation.googleWord(onCompleted: onComplete);
case GameBonus.androidSpaceship:
return BonusAnimation.androidSpaceship(onCompleted: onComplete);
}
}
}

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/game/pinball_game.dart'; import 'package:pinball/game/pinball_game.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/theme/theme.dart'; import 'package:pinball/select_character/select_character.dart';
/// {@template play_button_overlay} /// {@template play_button_overlay}
/// [Widget] that renders the button responsible to starting the game /// [Widget] that renders the button responsible to starting the game
@ -22,23 +22,9 @@ class PlayButtonOverlay extends StatelessWidget {
return Center( return Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () async {
_game.gameFlowController.start(); _game.gameFlowController.start();
showDialog<void>( await showCharacterSelectionDialog(context);
context: context,
barrierDismissible: false,
builder: (_) {
final height = MediaQuery.of(context).size.height * 0.5;
return Center(
child: SizedBox(
height: height,
width: height * 1.4,
child: const CharacterSelectionDialog(),
),
);
},
);
}, },
child: Text(l10n.play), child: Text(l10n.play),
), ),

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// {@template round_count_display}
/// Colored square indicating if a round is available.
/// {@endtemplate}
class RoundCountDisplay extends StatelessWidget {
/// {@macro round_count_display}
const RoundCountDisplay({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final rounds = context.select((GameBloc bloc) => bloc.state.rounds);
return Row(
children: [
Text(
l10n.rounds,
style: Theme.of(context).textTheme.subtitle1,
),
const SizedBox(width: 8),
Row(
children: [
RoundIndicator(isActive: rounds >= 1),
RoundIndicator(isActive: rounds >= 2),
RoundIndicator(isActive: rounds >= 3),
],
),
],
);
}
}
/// {@template round_indicator}
/// [Widget] that displays the round indicator.
/// {@endtemplate}
@visibleForTesting
class RoundIndicator extends StatelessWidget {
/// {@macro round_indicator}
const RoundIndicator({
Key? key,
required this.isActive,
}) : super(key: key);
/// A value that describes whether the indicator is active.
final bool isActive;
@override
Widget build(BuildContext context) {
final color =
isActive ? PinballColors.yellow : PinballColors.yellow.withAlpha(128);
const size = 8.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Container(
color: color,
height: size,
width: size,
),
);
}
}

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template score_view}
/// [Widget] that displays the score.
/// {@endtemplate}
class ScoreView extends StatelessWidget {
/// {@macro score_view}
const ScoreView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isGameOver = context.select((GameBloc bloc) => bloc.state.isGameOver);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: AnimatedSwitcher(
duration: kThemeAnimationDuration,
child: isGameOver ? const _GameOver() : const _ScoreDisplay(),
),
);
}
}
class _GameOver extends StatelessWidget {
const _GameOver({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Text(
l10n.gameOver,
style: Theme.of(context).textTheme.headline1,
);
}
}
class _ScoreDisplay extends StatelessWidget {
const _ScoreDisplay({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
l10n.score.toLowerCase(),
style: Theme.of(context).textTheme.subtitle1,
),
const _ScoreText(),
const RoundCountDisplay(),
],
);
}
}
class _ScoreText extends StatelessWidget {
const _ScoreText({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final score = context.select((GameBloc bloc) => bloc.state.score);
return Text(
score.formatScore(),
style: Theme.of(context).textTheme.headline1,
);
}
}

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

@ -14,26 +14,27 @@ class $AssetsImagesGen {
const $AssetsImagesBonusAnimationGen(); const $AssetsImagesBonusAnimationGen();
$AssetsImagesComponentsGen get components => $AssetsImagesComponentsGen get components =>
const $AssetsImagesComponentsGen(); const $AssetsImagesComponentsGen();
$AssetsImagesScoreGen get score => const $AssetsImagesScoreGen();
} }
class $AssetsImagesBonusAnimationGen { class $AssetsImagesBonusAnimationGen {
const $AssetsImagesBonusAnimationGen(); const $AssetsImagesBonusAnimationGen();
/// File path: assets/images/bonus_animation/android.png /// File path: assets/images/bonus_animation/android_spaceship.png
AssetGenImage get android => AssetGenImage get androidSpaceship => const AssetGenImage(
const AssetGenImage('assets/images/bonus_animation/android.png'); 'assets/images/bonus_animation/android_spaceship.png');
/// File path: assets/images/bonus_animation/dash_nest.png /// File path: assets/images/bonus_animation/dash_nest.png
AssetGenImage get dashNest => AssetGenImage get dashNest =>
const AssetGenImage('assets/images/bonus_animation/dash_nest.png'); const AssetGenImage('assets/images/bonus_animation/dash_nest.png');
/// File path: assets/images/bonus_animation/dino.png /// File path: assets/images/bonus_animation/dino_chomp.png
AssetGenImage get dino => AssetGenImage get dinoChomp =>
const AssetGenImage('assets/images/bonus_animation/dino.png'); const AssetGenImage('assets/images/bonus_animation/dino_chomp.png');
/// File path: assets/images/bonus_animation/google.png /// File path: assets/images/bonus_animation/google_word.png
AssetGenImage get google => AssetGenImage get googleWord =>
const AssetGenImage('assets/images/bonus_animation/google.png'); const AssetGenImage('assets/images/bonus_animation/google_word.png');
/// File path: assets/images/bonus_animation/sparky_turbo_charge.png /// File path: assets/images/bonus_animation/sparky_turbo_charge.png
AssetGenImage get sparkyTurboCharge => const AssetGenImage( AssetGenImage get sparkyTurboCharge => const AssetGenImage(
@ -46,6 +47,22 @@ class $AssetsImagesComponentsGen {
/// File path: assets/images/components/background.png /// File path: assets/images/components/background.png
AssetGenImage get background => AssetGenImage get background =>
const AssetGenImage('assets/images/components/background.png'); const AssetGenImage('assets/images/components/background.png');
/// File path: assets/images/components/key.png
AssetGenImage get key =>
const AssetGenImage('assets/images/components/key.png');
/// File path: assets/images/components/space.png
AssetGenImage get space =>
const AssetGenImage('assets/images/components/space.png');
}
class $AssetsImagesScoreGen {
const $AssetsImagesScoreGen();
/// File path: assets/images/score/mini_score_background.png
AssetGenImage get miniScoreBackground =>
const AssetGenImage('assets/images/score/mini_score_background.png');
} }
class Assets { class Assets {

@ -0,0 +1 @@
export 'widgets/widgets.dart';

@ -0,0 +1,305 @@
// ignore_for_file: public_member_api_docs
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pinball/gen/gen.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_ui/pinball_ui.dart';
import 'package:platform_helper/platform_helper.dart';
enum Control {
left,
right,
down,
a,
d,
s,
space,
}
extension on Control {
bool get isArrow => isDown || isRight || isLeft;
bool get isDown => this == Control.down;
bool get isRight => this == Control.right;
bool get isLeft => this == Control.left;
bool get isSpace => this == Control.space;
String getCharacter(BuildContext context) {
switch (this) {
case Control.a:
return 'A';
case Control.d:
return 'D';
case Control.down:
return '>'; // Will be rotated
case Control.left:
return '<';
case Control.right:
return '>';
case Control.s:
return 'S';
case Control.space:
return context.l10n.space;
}
}
}
Future<void> showHowToPlayDialog(BuildContext context) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => HowToPlayDialog(),
);
}
class HowToPlayDialog extends StatefulWidget {
HowToPlayDialog({
Key? key,
@visibleForTesting PlatformHelper? platformHelper,
}) : platformHelper = platformHelper ?? PlatformHelper(),
super(key: key);
final PlatformHelper platformHelper;
@override
State<HowToPlayDialog> createState() => _HowToPlayDialogState();
}
class _HowToPlayDialogState extends State<HowToPlayDialog> {
late Timer closeTimer;
@override
void initState() {
super.initState();
closeTimer = Timer(const Duration(seconds: 3), () {
if (mounted) {
Navigator.of(context).pop();
}
});
}
@override
void dispose() {
closeTimer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isMobile = widget.platformHelper.isMobile;
final l10n = context.l10n;
return PinballDialog(
title: l10n.howToPlay,
subtitle: l10n.tipsForFlips,
child: isMobile ? const _MobileBody() : const _DesktopBody(),
);
}
}
class _MobileBody extends StatelessWidget {
const _MobileBody({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final paddingWidth = MediaQuery.of(context).size.width * 0.15;
final paddingHeight = MediaQuery.of(context).size.height * 0.075;
return FittedBox(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: paddingWidth,
),
child: Column(
children: [
const _MobileLaunchControls(),
SizedBox(height: paddingHeight),
const _MobileFlipperControls(),
],
),
),
);
}
}
class _MobileLaunchControls extends StatelessWidget {
const _MobileLaunchControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final headline3 = Theme.of(context)
.textTheme
.headline3!
.copyWith(color: PinballColors.white);
return Column(
children: [
Text(l10n.tapAndHoldRocket, style: headline3),
Text.rich(
TextSpan(
children: [
TextSpan(text: '${l10n.to} ', style: headline3),
TextSpan(
text: l10n.launch,
style: headline3.copyWith(color: PinballColors.blue),
),
],
),
),
],
);
}
}
class _MobileFlipperControls extends StatelessWidget {
const _MobileFlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final headline3 = Theme.of(context)
.textTheme
.headline3!
.copyWith(color: PinballColors.white);
return Column(
children: [
Text(l10n.tapLeftRightScreen, style: headline3),
Text.rich(
TextSpan(
children: [
TextSpan(text: '${l10n.to} ', style: headline3),
TextSpan(
text: l10n.flip,
style: headline3.copyWith(color: PinballColors.orange),
),
],
),
),
],
);
}
}
class _DesktopBody extends StatelessWidget {
const _DesktopBody({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListView(
children: const [
SizedBox(height: 16),
_DesktopLaunchControls(),
SizedBox(height: 16),
_DesktopFlipperControls(),
],
);
}
}
class _DesktopLaunchControls extends StatelessWidget {
const _DesktopLaunchControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
children: [
Text(
l10n.launchControls,
style: Theme.of(context).textTheme.headline4,
),
const SizedBox(height: 10),
Wrap(
children: const [
_KeyButton(control: Control.down),
SizedBox(width: 10),
_KeyButton(control: Control.space),
SizedBox(width: 10),
_KeyButton(control: Control.s),
],
)
],
);
}
}
class _DesktopFlipperControls extends StatelessWidget {
const _DesktopFlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
children: [
Text(
l10n.flipperControls,
style: Theme.of(context).textTheme.subtitle2,
),
const SizedBox(height: 10),
Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
_KeyButton(control: Control.left),
SizedBox(width: 20),
_KeyButton(control: Control.right),
],
),
const SizedBox(height: 8),
Wrap(
children: const [
_KeyButton(control: Control.a),
SizedBox(width: 20),
_KeyButton(control: Control.d),
],
)
],
)
],
);
}
}
class _KeyButton extends StatelessWidget {
const _KeyButton({Key? key, required this.control}) : super(key: key);
final Control control;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final textStyle =
control.isArrow ? textTheme.headline1 : textTheme.headline3;
const height = 60.0;
final width = control.isSpace ? height * 2.83 : height;
return DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.fill,
image: AssetImage(
control.isSpace
? Assets.images.components.space.keyName
: Assets.images.components.key.keyName,
),
),
),
child: SizedBox(
width: width,
height: height,
child: Center(
child: RotatedBox(
quarterTurns: control.isDown ? 1 : 0,
child: Text(
control.getCharacter(context),
style: textStyle?.copyWith(color: PinballColors.white),
),
),
),
),
);
}
}

@ -8,6 +8,10 @@
"@howToPlay": { "@howToPlay": {
"description": "Text displayed on the landing page how to play button" "description": "Text displayed on the landing page how to play button"
}, },
"tipsForFlips": "Tips for flips",
"@tipsForFlips": {
"description": "Text displayed on the landing page how to play button"
},
"launchControls": "Launch Controls", "launchControls": "Launch Controls",
"@launchControls": { "@launchControls": {
"description": "Text displayed on the how to play dialog with the launch controls" "description": "Text displayed on the how to play dialog with the launch controls"
@ -16,21 +20,45 @@
"@flipperControls": { "@flipperControls": {
"description": "Text displayed on the how to play dialog with the flipper controls" "description": "Text displayed on the how to play dialog with the flipper controls"
}, },
"tapAndHoldRocket": "Tap & Hold Rocket",
"@tapAndHoldRocket": {
"description": "Text displayed on the how to launch on mobile"
},
"to": "to",
"@to": {
"description": "Text displayed for the word to"
},
"launch": "LAUNCH",
"@launch": {
"description": "Text displayed for the word launch"
},
"tapLeftRightScreen": "Tap left/right screen",
"@tapLeftRightScreen": {
"description": "Text displayed on the how to flip on mobile"
},
"flip": "FLIP",
"@flip": {
"description": "Text displayed for the word FLIP"
},
"start": "Start", "start": "Start",
"@start": { "@start": {
"description": "Text displayed on the character selection page start button" "description": "Text displayed on the character selection page start button"
}, },
"select": "Select", "select": "Select",
"@select": { "@select": {
"description": "Text displayed on the character selection page select button" "description": "Text displayed on the character selection dialog - select button"
}, },
"characterSelectionTitle": "Choose your character!", "space": "Space",
"@space": {
"description": "Text displayed on space control button"
},
"characterSelectionTitle": "Select a Character",
"@characterSelectionTitle": { "@characterSelectionTitle": {
"description": "Title text displayed on the character selection page" "description": "Title text displayed on the character selection dialog"
}, },
"characterSelectionSubtitle": "Theres no wrong answer", "characterSelectionSubtitle": "Theres no wrong choice!",
"@characterSelectionSubtitle": { "@characterSelectionSubtitle": {
"description": "Text displayed on the selecting character dialog at game beginning" "description": "Subtitle text displayed on the character selection dialog"
}, },
"gameOver": "Game Over", "gameOver": "Game Over",
"@gameOver": { "@gameOver": {
@ -75,5 +103,25 @@
"enterInitials": "Enter your initials", "enterInitials": "Enter your initials",
"@enterInitials": { "@enterInitials": {
"description": "Text displayed on the ending dialog when game finishes to ask the user for his initials" "description": "Text displayed on the ending dialog when game finishes to ask the user for his initials"
},
"rounds": "Ball Ct:",
"@rounds": {
"description": "Text displayed on the scoreboard widget to indicate rounds left"
},
"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"
} }
} }

@ -1,15 +0,0 @@
{
"@@locale": "es",
"play": "Jugar",
"@play": {
"description": "Text displayed on the landing page play button"
},
"start": "Comienzo",
"@start": {
"description": "Text displayed on the character selection page start button"
},
"characterSelectionTitle": "¡Elige a tu personaje!",
"@characterSelectionTitle": {
"description": "Title text displayed on the character selection page"
}
}

@ -1,71 +0,0 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
part 'leaderboard_event.dart';
part 'leaderboard_state.dart';
/// {@template leaderboard_bloc}
/// Manages leaderboard events.
///
/// Uses a [LeaderboardRepository] to request and update players participations.
/// {@endtemplate}
class LeaderboardBloc extends Bloc<LeaderboardEvent, LeaderboardState> {
/// {@macro leaderboard_bloc}
LeaderboardBloc(this._leaderboardRepository)
: super(const LeaderboardState.initial()) {
on<Top10Fetched>(_onTop10Fetched);
on<LeaderboardEntryAdded>(_onLeaderboardEntryAdded);
}
final LeaderboardRepository _leaderboardRepository;
Future<void> _onTop10Fetched(
Top10Fetched event,
Emitter<LeaderboardState> emit,
) async {
emit(state.copyWith(status: LeaderboardStatus.loading));
try {
final top10Leaderboard =
await _leaderboardRepository.fetchTop10Leaderboard();
final leaderboardEntries = <LeaderboardEntry>[];
top10Leaderboard.asMap().forEach(
(index, value) => leaderboardEntries.add(value.toEntry(index + 1)),
);
emit(
state.copyWith(
status: LeaderboardStatus.success,
leaderboard: leaderboardEntries,
),
);
} catch (error) {
emit(state.copyWith(status: LeaderboardStatus.error));
addError(error);
}
}
Future<void> _onLeaderboardEntryAdded(
LeaderboardEntryAdded event,
Emitter<LeaderboardState> emit,
) async {
emit(state.copyWith(status: LeaderboardStatus.loading));
try {
final ranking =
await _leaderboardRepository.addLeaderboardEntry(event.entry);
emit(
state.copyWith(
status: LeaderboardStatus.success,
ranking: ranking,
),
);
} catch (error) {
emit(state.copyWith(status: LeaderboardStatus.error));
addError(error);
}
}
}

@ -1,36 +0,0 @@
part of 'leaderboard_bloc.dart';
/// {@template leaderboard_event}
/// Represents the events available for [LeaderboardBloc].
/// {endtemplate}
abstract class LeaderboardEvent extends Equatable {
/// {@macro leaderboard_event}
const LeaderboardEvent();
}
/// {@template top_10_fetched}
/// Request the top 10 [LeaderboardEntryData]s.
/// {endtemplate}
class Top10Fetched extends LeaderboardEvent {
/// {@macro top_10_fetched}
const Top10Fetched();
@override
List<Object?> get props => [];
}
/// {@template leaderboard_entry_added}
/// Writes a new [LeaderboardEntryData].
///
/// Should be added when a player finishes a game.
/// {endtemplate}
class LeaderboardEntryAdded extends LeaderboardEvent {
/// {@macro leaderboard_entry_added}
const LeaderboardEntryAdded({required this.entry});
/// [LeaderboardEntryData] to be written to the remote storage.
final LeaderboardEntryData entry;
@override
List<Object?> get props => [entry];
}

@ -1,59 +0,0 @@
// ignore_for_file: public_member_api_docs
part of 'leaderboard_bloc.dart';
/// Defines the request status.
enum LeaderboardStatus {
/// Request is being loaded.
loading,
/// Request was processed successfully and received a valid response.
success,
/// Request was processed unsuccessfully and received an error.
error,
}
/// {@template leaderboard_state}
/// Represents the state of the leaderboard.
/// {@endtemplate}
class LeaderboardState extends Equatable {
/// {@macro leaderboard_state}
const LeaderboardState({
required this.status,
required this.ranking,
required this.leaderboard,
});
const LeaderboardState.initial()
: status = LeaderboardStatus.loading,
ranking = const LeaderboardRanking(
ranking: 0,
outOf: 0,
),
leaderboard = const [];
/// The current [LeaderboardStatus] of the state.
final LeaderboardStatus status;
/// Rank of the current player.
final LeaderboardRanking ranking;
/// List of top-ranked players.
final List<LeaderboardEntry> leaderboard;
@override
List<Object> get props => [status, ranking, leaderboard];
LeaderboardState copyWith({
LeaderboardStatus? status,
LeaderboardRanking? ranking,
List<LeaderboardEntry>? leaderboard,
}) {
return LeaderboardState(
status: status ?? this.status,
ranking: ranking ?? this.ranking,
leaderboard: leaderboard ?? this.leaderboard,
);
}
}

@ -1,3 +0,0 @@
export 'bloc/leaderboard_bloc.dart';
export 'models/leader_board_entry.dart';
export 'view/leaderboard_page.dart';

@ -1,306 +0,0 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_theme/pinball_theme.dart';
class LeaderboardPage extends StatelessWidget {
const LeaderboardPage({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
static Route route({required CharacterTheme theme}) {
return MaterialPageRoute<void>(
builder: (_) => LeaderboardPage(theme: theme),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LeaderboardBloc(
context.read<LeaderboardRepository>(),
)..add(const Top10Fetched()),
child: LeaderboardView(theme: theme),
);
}
}
class LeaderboardView extends StatelessWidget {
const LeaderboardView({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 80),
Text(
l10n.leaderboard,
style: Theme.of(context).textTheme.headline3,
),
const SizedBox(height: 80),
BlocBuilder<LeaderboardBloc, LeaderboardState>(
builder: (context, state) {
switch (state.status) {
case LeaderboardStatus.loading:
return _LeaderboardLoading(theme: theme);
case LeaderboardStatus.success:
return _LeaderboardRanking(
ranking: state.leaderboard,
theme: theme,
);
case LeaderboardStatus.error:
return _LeaderboardError(theme: theme);
}
},
),
const SizedBox(height: 20),
TextButton(
onPressed: () => Navigator.of(context).push<void>(
CharacterSelectionDialog.route(),
),
child: Text(l10n.retry),
),
],
),
),
),
);
}
}
class _LeaderboardLoading extends StatelessWidget {
const _LeaderboardLoading({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return const Center(
child: CircularProgressIndicator(),
);
}
}
class _LeaderboardError extends StatelessWidget {
const _LeaderboardError({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Text(
'There was en error loading data!',
style:
Theme.of(context).textTheme.headline6?.copyWith(color: Colors.red),
),
);
}
}
class _LeaderboardRanking extends StatelessWidget {
const _LeaderboardRanking({
Key? key,
required this.ranking,
required this.theme,
}) : super(key: key);
final List<LeaderboardEntry> ranking;
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LeaderboardHeaders(theme: theme),
_LeaderboardList(
ranking: ranking,
theme: theme,
),
],
),
);
}
}
class _LeaderboardHeaders extends StatelessWidget {
const _LeaderboardHeaders({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LeaderboardHeaderItem(title: l10n.rank, theme: theme),
_LeaderboardHeaderItem(title: l10n.character, theme: theme),
_LeaderboardHeaderItem(title: l10n.username, theme: theme),
_LeaderboardHeaderItem(title: l10n.score, theme: theme),
],
);
}
}
class _LeaderboardHeaderItem extends StatelessWidget {
const _LeaderboardHeaderItem({
Key? key,
required this.title,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final String title;
@override
Widget build(BuildContext context) {
return Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.ballColor,
),
child: Text(
title,
style: Theme.of(context).textTheme.headline5,
),
),
);
}
}
class _LeaderboardList extends StatelessWidget {
const _LeaderboardList({
Key? key,
required this.ranking,
required this.theme,
}) : super(key: key);
final List<LeaderboardEntry> ranking;
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (_, index) => _LeaderBoardCompetitor(
entry: ranking[index],
theme: theme,
),
itemCount: ranking.length,
);
}
}
class _LeaderBoardCompetitor extends StatelessWidget {
const _LeaderBoardCompetitor({
Key? key,
required this.entry,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final LeaderboardEntry entry;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LeaderboardCompetitorField(
text: entry.rank,
theme: theme,
),
_LeaderboardCompetitorCharacter(
characterAsset: entry.character,
theme: theme,
),
_LeaderboardCompetitorField(
text: entry.playerInitials,
theme: theme,
),
_LeaderboardCompetitorField(
text: entry.score.toString(),
theme: theme,
),
],
);
}
}
class _LeaderboardCompetitorField extends StatelessWidget {
const _LeaderboardCompetitorField({
Key? key,
required this.text,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final String text;
@override
Widget build(BuildContext context) {
return Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: theme.ballColor,
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(text),
),
),
);
}
}
class _LeaderboardCompetitorCharacter extends StatelessWidget {
const _LeaderboardCompetitorCharacter({
Key? key,
required this.characterAsset,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final AssetGenImage characterAsset;
@override
Widget build(BuildContext context) {
return Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: theme.ballColor,
width: 2,
),
),
child: SizedBox(
height: 30,
child: characterAsset.image(),
),
),
);
}
}

@ -5,16 +5,27 @@
// license that can be found in the LICENSE file or at // license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT. // https://opensource.org/licenses/MIT.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/app/app.dart'; import 'package:pinball/app/app.dart';
import 'package:pinball/bootstrap.dart'; import 'package:pinball/bootstrap.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
void main() { void main() {
bootstrap((firestore) async { bootstrap((firestore, firebaseAuth) async {
final leaderboardRepository = LeaderboardRepository(firestore); final leaderboardRepository = LeaderboardRepository(firestore);
final authenticationRepository = AuthenticationRepository(firebaseAuth);
final pinballAudio = PinballAudio(); final pinballAudio = PinballAudio();
unawaited(
Firebase.initializeApp().then(
(_) => authenticationRepository.authenticateAnonymously(),
),
);
return App( return App(
authenticationRepository: authenticationRepository,
leaderboardRepository: leaderboardRepository, leaderboardRepository: leaderboardRepository,
pinballAudio: pinballAudio, pinballAudio: pinballAudio,
); );

@ -5,16 +5,27 @@
// license that can be found in the LICENSE file or at // license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT. // https://opensource.org/licenses/MIT.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/app/app.dart'; import 'package:pinball/app/app.dart';
import 'package:pinball/bootstrap.dart'; import 'package:pinball/bootstrap.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
void main() { void main() {
bootstrap((firestore) async { bootstrap((firestore, firebaseAuth) async {
final leaderboardRepository = LeaderboardRepository(firestore); final leaderboardRepository = LeaderboardRepository(firestore);
final authenticationRepository = AuthenticationRepository(firebaseAuth);
final pinballAudio = PinballAudio(); final pinballAudio = PinballAudio();
unawaited(
Firebase.initializeApp().then(
(_) => authenticationRepository.authenticateAnonymously(),
),
);
return App( return App(
authenticationRepository: authenticationRepository,
leaderboardRepository: leaderboardRepository, leaderboardRepository: leaderboardRepository,
pinballAudio: pinballAudio, pinballAudio: pinballAudio,
); );

@ -5,16 +5,27 @@
// license that can be found in the LICENSE file or at // license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT. // https://opensource.org/licenses/MIT.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/app/app.dart'; import 'package:pinball/app/app.dart';
import 'package:pinball/bootstrap.dart'; import 'package:pinball/bootstrap.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
void main() { void main() {
bootstrap((firestore) async { bootstrap((firestore, firebaseAuth) async {
final leaderboardRepository = LeaderboardRepository(firestore); final leaderboardRepository = LeaderboardRepository(firestore);
final authenticationRepository = AuthenticationRepository(firebaseAuth);
final pinballAudio = PinballAudio(); final pinballAudio = PinballAudio();
unawaited(
Firebase.initializeApp().then(
(_) => authenticationRepository.authenticateAnonymously(),
),
);
return App( return App(
authenticationRepository: authenticationRepository,
leaderboardRepository: leaderboardRepository, leaderboardRepository: leaderboardRepository,
pinballAudio: pinballAudio, pinballAudio: pinballAudio,
); );

@ -5,12 +5,12 @@ import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_theme/pinball_theme.dart';
part 'theme_state.dart'; part 'character_theme_state.dart';
class ThemeCubit extends Cubit<ThemeState> { class CharacterThemeCubit extends Cubit<CharacterThemeState> {
ThemeCubit() : super(const ThemeState.initial()); CharacterThemeCubit() : super(const CharacterThemeState.initial());
void characterSelected(CharacterTheme characterTheme) { void characterSelected(CharacterTheme characterTheme) {
emit(ThemeState(PinballTheme(characterTheme: characterTheme))); emit(CharacterThemeState(characterTheme));
} }
} }

@ -0,0 +1,23 @@
// ignore_for_file: public_member_api_docs
// TODO(allisonryan0002): Document this section when the API is stable.
part of 'character_theme_cubit.dart';
class CharacterThemeState extends Equatable {
const CharacterThemeState(this.characterTheme);
const CharacterThemeState.initial() : characterTheme = const DashTheme();
final CharacterTheme characterTheme;
bool get isSparkySelected => characterTheme == const SparkyTheme();
bool get isDashSelected => characterTheme == const DashTheme();
bool get isAndroidSelected => characterTheme == const AndroidTheme();
bool get isDinoSelected => characterTheme == const DinoTheme();
@override
List<Object> get props => [characterTheme];
}

@ -0,0 +1,2 @@
export 'cubit/character_theme_cubit.dart';
export 'view/view.dart';

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/how_to_play/how_to_play.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/select_character/cubit/character_theme_cubit.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_theme/pinball_theme.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// Inflates [CharacterSelectionDialog] using [showDialog].
Future<void> showCharacterSelectionDialog(BuildContext context) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const CharacterSelectionDialog(),
);
}
/// {@template character_selection_dialog}
/// Dialog used to select the playing character of the game.
/// {@endtemplate character_selection_dialog}
class CharacterSelectionDialog extends StatelessWidget {
/// {@macro character_selection_dialog}
const CharacterSelectionDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return PinballDialog(
title: l10n.characterSelectionTitle,
subtitle: l10n.characterSelectionSubtitle,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Expanded(child: _CharacterPreview()),
Expanded(child: _CharacterGrid()),
],
),
),
const SizedBox(height: 8),
const _SelectCharacterButton(),
],
),
),
);
}
}
class _SelectCharacterButton extends StatelessWidget {
const _SelectCharacterButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return PinballButton(
onTap: () async {
Navigator.of(context).pop();
await showHowToPlayDialog(context);
},
text: l10n.select,
);
}
}
class _CharacterGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<CharacterThemeCubit, CharacterThemeState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
children: [
_Character(
key: const Key('sparky_character_selection'),
character: const SparkyTheme(),
isSelected: state.isSparkySelected,
),
const SizedBox(height: 6),
_Character(
key: const Key('android_character_selection'),
character: const AndroidTheme(),
isSelected: state.isAndroidSelected,
),
],
),
const SizedBox(width: 6),
Column(
children: [
_Character(
key: const Key('dash_character_selection'),
character: const DashTheme(),
isSelected: state.isDashSelected,
),
const SizedBox(height: 6),
_Character(
key: const Key('dino_character_selection'),
character: const DinoTheme(),
isSelected: state.isDinoSelected,
),
],
),
],
);
},
);
}
}
class _CharacterPreview extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<CharacterThemeCubit, CharacterThemeState>(
builder: (context, state) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
state.characterTheme.name,
style: Theme.of(context).textTheme.headline2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Expanded(child: state.characterTheme.icon.image()),
],
);
},
);
}
}
class _Character extends StatelessWidget {
const _Character({
Key? key,
required this.character,
required this.isSelected,
}) : super(key: key);
final CharacterTheme character;
final bool isSelected;
@override
Widget build(BuildContext context) {
return Expanded(
child: Opacity(
opacity: isSelected ? 1 : 0.3,
child: InkWell(
onTap: () =>
context.read<CharacterThemeCubit>().characterSelected(character),
child: character.icon.image(fit: BoxFit.contain),
),
),
);
}
}

@ -1,2 +1 @@
export 'bloc/start_game_bloc.dart'; export 'bloc/start_game_bloc.dart';
export 'widgets/widgets.dart';

@ -1,161 +0,0 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:pinball/l10n/l10n.dart';
class HowToPlayDialog extends StatelessWidget {
const HowToPlayDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const spacing = SizedBox(height: 16);
return Dialog(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.howToPlay),
spacing,
const _LaunchControls(),
spacing,
const _FlipperControls(),
],
),
),
);
}
}
class _LaunchControls extends StatelessWidget {
const _LaunchControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const spacing = SizedBox(width: 10);
return Column(
children: [
Text(l10n.launchControls),
const SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_down),
spacing,
KeyIndicator.fromKeyName(keyName: 'SPACE'),
spacing,
KeyIndicator.fromKeyName(keyName: 'S'),
],
)
],
);
}
}
class _FlipperControls extends StatelessWidget {
const _FlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const rowSpacing = SizedBox(width: 20);
return Column(
children: [
Text(l10n.flipperControls),
const SizedBox(height: 10),
Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_left),
rowSpacing,
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_right),
],
),
const SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
KeyIndicator.fromKeyName(keyName: 'A'),
rowSpacing,
KeyIndicator.fromKeyName(keyName: 'D'),
],
)
],
)
],
);
}
}
// TODO(allisonryan0002): remove visibility when adding final UI.
@visibleForTesting
class KeyIndicator extends StatelessWidget {
const KeyIndicator._({
Key? key,
required String keyName,
required IconData keyIcon,
required bool fromIcon,
}) : _keyName = keyName,
_keyIcon = keyIcon,
_fromIcon = fromIcon,
super(key: key);
const KeyIndicator.fromKeyName({Key? key, required String keyName})
: this._(
key: key,
keyName: keyName,
keyIcon: Icons.keyboard_arrow_down,
fromIcon: false,
);
const KeyIndicator.fromIcon({Key? key, required IconData keyIcon})
: this._(
key: key,
keyName: '',
keyIcon: keyIcon,
fromIcon: true,
);
final String _keyName;
final IconData _keyIcon;
final bool _fromIcon;
@override
Widget build(BuildContext context) {
const iconPadding = EdgeInsets.all(15);
const textPadding = EdgeInsets.symmetric(vertical: 20, horizontal: 22);
final boarderColor = Colors.blue.withOpacity(0.5);
final color = Colors.blue.withOpacity(0.7);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: boarderColor,
width: 3,
),
),
child: _fromIcon
? Padding(
padding: iconPadding,
child: Icon(_keyIcon, color: color),
)
: Padding(
padding: textPadding,
child: Text(_keyName, style: TextStyle(color: color)),
),
);
}
}

@ -1,35 +0,0 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/widgets.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_components/pinball_components.dart';
const _fontPackage = 'pinball_components';
const _primaryFontFamily = PinballFonts.pixeloidSans;
abstract class AppTextStyle {
static const headline1 = TextStyle(
fontSize: 28,
package: _fontPackage,
fontFamily: _primaryFontFamily,
);
static const headline2 = TextStyle(
fontSize: 24,
package: _fontPackage,
fontFamily: _primaryFontFamily,
);
static const headline3 = TextStyle(
color: AppColors.white,
fontSize: 20,
package: _fontPackage,
fontFamily: _primaryFontFamily,
);
static const subtitle1 = TextStyle(
fontSize: 10,
fontFamily: _primaryFontFamily,
package: _fontPackage,
);
}

@ -1,16 +0,0 @@
// ignore_for_file: public_member_api_docs
// TODO(allisonryan0002): Document this section when the API is stable.
part of 'theme_cubit.dart';
class ThemeState extends Equatable {
const ThemeState(this.theme);
const ThemeState.initial()
: theme = const PinballTheme(characterTheme: DashTheme());
final PinballTheme theme;
@override
List<Object> get props => [theme];
}

@ -1,4 +0,0 @@
export 'app_colors.dart';
export 'app_text_style.dart';
export 'cubit/theme_cubit.dart';
export 'view/view.dart';

@ -1,132 +0,0 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/start_game/start_game.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_theme/pinball_theme.dart';
class CharacterSelectionDialog extends StatelessWidget {
const CharacterSelectionDialog({Key? key}) : super(key: key);
static Route route() {
return MaterialPageRoute<void>(
builder: (_) => const CharacterSelectionDialog(),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ThemeCubit(),
child: const CharacterSelectionView(),
);
}
}
class CharacterSelectionView extends StatelessWidget {
const CharacterSelectionView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
body: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 80),
Text(
l10n.characterSelectionTitle,
style: Theme.of(context).textTheme.headline3,
),
const SizedBox(height: 80),
const _CharacterSelectionGridView(),
const SizedBox(height: 20),
TextButton(
onPressed: () {
Navigator.of(context).pop();
showDialog<void>(
context: context,
builder: (_) => const HowToPlayDialog(),
);
},
child: Text(l10n.start),
),
],
),
),
);
}
}
class _CharacterSelectionGridView extends StatelessWidget {
const _CharacterSelectionGridView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
mainAxisSpacing: 20,
crossAxisSpacing: 20,
children: const [
CharacterImageButton(
DashTheme(),
key: Key('characterSelectionPage_dashButton'),
),
CharacterImageButton(
SparkyTheme(),
key: Key('characterSelectionPage_sparkyButton'),
),
CharacterImageButton(
AndroidTheme(),
key: Key('characterSelectionPage_androidButton'),
),
CharacterImageButton(
DinoTheme(),
key: Key('characterSelectionPage_dinoButton'),
),
],
),
);
}
}
// TODO(allisonryan0002): remove visibility when adding final UI.
@visibleForTesting
class CharacterImageButton extends StatelessWidget {
const CharacterImageButton(
this.characterTheme, {
Key? key,
}) : super(key: key);
final CharacterTheme characterTheme;
@override
Widget build(BuildContext context) {
final currentCharacterTheme = context.select<ThemeCubit, CharacterTheme>(
(cubit) => cubit.state.theme.characterTheme,
);
return GestureDetector(
onTap: () => context.read<ThemeCubit>().characterSelected(characterTheme),
child: DecoratedBox(
decoration: BoxDecoration(
color: (currentCharacterTheme == characterTheme)
? Colors.blue.withOpacity(0.5)
: null,
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: characterTheme.icon.image(),
),
),
);
}
}

@ -0,0 +1,39 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VSCode related
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json

@ -0,0 +1,11 @@
# authentication_repository
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
Repository to manage user authentication.
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis

@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml

@ -0,0 +1,3 @@
library authentication_repository;
export 'src/authentication_repository.dart';

@ -0,0 +1,36 @@
import 'package:firebase_auth/firebase_auth.dart';
/// {@template authentication_exception}
/// Exception for authentication repository failures.
/// {@endtemplate}
class AuthenticationException implements Exception {
/// {@macro authentication_exception}
const AuthenticationException(this.error, this.stackTrace);
/// The error that was caught.
final Object error;
/// The Stacktrace associated with the [error].
final StackTrace stackTrace;
}
/// {@template authentication_repository}
/// Repository to manage user authentication.
/// {@endtemplate}
class AuthenticationRepository {
/// {@macro authentication_repository}
AuthenticationRepository(this._firebaseAuth);
final FirebaseAuth _firebaseAuth;
/// Sign in the existing user anonymously using [FirebaseAuth]. If the
/// authentication process can't be completed, it will throw an
/// [AuthenticationException].
Future<void> authenticateAnonymously() async {
try {
await _firebaseAuth.signInAnonymously();
} on Exception catch (error, stackTrace) {
throw AuthenticationException(error, stackTrace);
}
}
}

@ -0,0 +1,18 @@
name: authentication_repository
description: Repository to manage user authentication.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=2.16.0 <3.0.0"
dependencies:
firebase_auth: ^3.3.16
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^0.2.0
very_good_analysis: ^2.4.0

@ -0,0 +1,40 @@
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockFirebaseAuth extends Mock implements FirebaseAuth {}
class MockUserCredential extends Mock implements UserCredential {}
void main() {
late FirebaseAuth firebaseAuth;
late UserCredential userCredential;
late AuthenticationRepository authenticationRepository;
group('AuthenticationRepository', () {
setUp(() {
firebaseAuth = MockFirebaseAuth();
userCredential = MockUserCredential();
authenticationRepository = AuthenticationRepository(firebaseAuth);
});
group('authenticateAnonymously', () {
test('completes if no exception is thrown', () async {
when(() => firebaseAuth.signInAnonymously())
.thenAnswer((_) async => userCredential);
await authenticationRepository.authenticateAnonymously();
verify(() => firebaseAuth.signInAnonymously()).called(1);
});
test('throws AuthenticationException when firebase auth fails', () async {
when(() => firebaseAuth.signInAnonymously())
.thenThrow(Exception('oops'));
expect(
() => authenticationRepository.authenticateAnonymously(),
throwsA(isA<AuthenticationException>()),
);
});
});
});
}

@ -1,91 +1,6 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
/// {@template leaderboard_exception}
/// Base exception for leaderboard repository failures.
/// {@endtemplate}
abstract class LeaderboardException implements Exception {
/// {@macro leaderboard_exception}
const LeaderboardException(this.error, this.stackTrace);
/// The error that was caught.
final Object error;
/// The Stacktrace associated with the [error].
final StackTrace stackTrace;
}
/// {@template leaderboard_deserialization_exception}
/// Exception thrown when leaderboard data cannot be deserialized in the
/// expected way.
/// {@endtemplate}
class LeaderboardDeserializationException extends LeaderboardException {
/// {@macro leaderboard_deserialization_exception}
const LeaderboardDeserializationException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template fetch_top_10_leaderboard_exception}
/// Exception thrown when failure occurs while fetching top 10 leaderboard.
/// {@endtemplate}
class FetchTop10LeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const FetchTop10LeaderboardException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template add_leaderboard_entry_exception}
/// Exception thrown when failure occurs while adding entry to leaderboard.
/// {@endtemplate}
class AddLeaderboardEntryException extends LeaderboardException {
/// {@macro add_leaderboard_entry_exception}
const AddLeaderboardEntryException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template fetch_player_ranking_exception}
/// Exception thrown when failure occurs while fetching player ranking.
/// {@endtemplate}
class FetchPlayerRankingException extends LeaderboardException {
/// {@macro fetch_player_ranking_exception}
const FetchPlayerRankingException(
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,
);
}
/// {@template leaderboard_repository} /// {@template leaderboard_repository}
/// Repository to access leaderboard data in Firebase Cloud Firestore. /// Repository to access leaderboard data in Firebase Cloud Firestore.
/// {@endtemplate} /// {@endtemplate}
@ -97,73 +12,40 @@ class LeaderboardRepository {
final FirebaseFirestore _firebaseFirestore; final FirebaseFirestore _firebaseFirestore;
static const _leaderboardLimit = 10;
static const _leaderboardCollectionName = 'leaderboard';
static const _scoreFieldName = 'score';
/// Acquires top 10 [LeaderboardEntryData]s. /// Acquires top 10 [LeaderboardEntryData]s.
Future<List<LeaderboardEntryData>> fetchTop10Leaderboard() async { Future<List<LeaderboardEntryData>> fetchTop10Leaderboard() async {
final leaderboardEntries = <LeaderboardEntryData>[];
late List<QueryDocumentSnapshot> documents;
try { try {
final querySnapshot = await _firebaseFirestore final querySnapshot = await _firebaseFirestore
.collection('leaderboard') .collection(_leaderboardCollectionName)
.orderBy('score', descending: true) .orderBy(_scoreFieldName, descending: true)
.limit(10) .limit(_leaderboardLimit)
.get(); .get();
documents = querySnapshot.docs; final documents = querySnapshot.docs;
return documents.toLeaderboard();
} on LeaderboardDeserializationException {
rethrow;
} on Exception catch (error, stackTrace) { } on Exception catch (error, stackTrace) {
throw FetchTop10LeaderboardException(error, stackTrace); throw FetchTop10LeaderboardException(error, stackTrace);
} }
for (final document in documents) {
final data = document.data() as Map<String, dynamic>?;
if (data != null) {
try {
leaderboardEntries.add(LeaderboardEntryData.fromJson(data));
} catch (error, stackTrace) {
throw LeaderboardDeserializationException(error, stackTrace);
}
}
} }
return leaderboardEntries; /// Adds player's score entry to the leaderboard if it is within the top-10
} Future<void> addLeaderboardEntry(
/// Adds player's score entry to the leaderboard and gets their
/// [LeaderboardRanking].
Future<LeaderboardRanking> addLeaderboardEntry(
LeaderboardEntryData entry, LeaderboardEntryData entry,
) async { ) async {
late DocumentReference entryReference; final leaderboard = await _fetchLeaderboardSortedByScore();
try { if (leaderboard.length < 10) {
entryReference = await _firebaseFirestore await _saveScore(entry);
.collection('leaderboard')
.add(entry.toJson());
} on Exception catch (error, stackTrace) {
throw AddLeaderboardEntryException(error, stackTrace);
}
try {
final querySnapshot = await _firebaseFirestore
.collection('leaderboard')
.orderBy('score', descending: true)
.get();
// TODO(allisonryan0002): see if we can find a more performant solution.
final documents = querySnapshot.docs;
final ranking = documents.indexWhere(
(document) => document.id == entryReference.id,
) +
1;
if (ranking > 0) {
return LeaderboardRanking(ranking: ranking, outOf: documents.length);
} else { } else {
throw FetchPlayerRankingException( final tenthPositionScore = leaderboard[9].score;
'Player score could not be found and ranking cannot be provided.', if (entry.score > tenthPositionScore) {
StackTrace.current, await _saveScore(entry);
); await _deleteScoresUnder(tenthPositionScore);
} }
} on Exception catch (error, stackTrace) {
throw FetchPlayerRankingException(error, stackTrace);
} }
} }
@ -174,7 +56,6 @@ class LeaderboardRepository {
if (!initialsRegex.hasMatch(initials)) { if (!initialsRegex.hasMatch(initials)) {
return false; return false;
} }
try { try {
final document = await _firebaseFirestore final document = await _firebaseFirestore
.collection('prohibitedInitials') .collection('prohibitedInitials')
@ -187,4 +68,61 @@ class LeaderboardRepository {
throw FetchProhibitedInitialsException(error, stackTrace); throw FetchProhibitedInitialsException(error, stackTrace);
} }
} }
Future<List<LeaderboardEntryData>> _fetchLeaderboardSortedByScore() async {
try {
final querySnapshot = await _firebaseFirestore
.collection(_leaderboardCollectionName)
.orderBy(_scoreFieldName, descending: true)
.get();
final documents = querySnapshot.docs;
return documents.toLeaderboard();
} on Exception catch (error, stackTrace) {
throw FetchLeaderboardException(error, stackTrace);
}
}
Future<void> _saveScore(LeaderboardEntryData entry) {
try {
return _firebaseFirestore
.collection(_leaderboardCollectionName)
.add(entry.toJson());
} on Exception catch (error, stackTrace) {
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> {
List<LeaderboardEntryData> toLeaderboard() {
final leaderboardEntries = <LeaderboardEntryData>[];
for (final document in this) {
final data = document.data() as Map<String, dynamic>?;
if (data != null) {
try {
leaderboardEntries.add(LeaderboardEntryData.fromJson(data));
} catch (error, stackTrace) {
throw LeaderboardDeserializationException(error, stackTrace);
}
}
}
return leaderboardEntries;
}
} }

@ -0,0 +1,69 @@
/// {@template leaderboard_exception}
/// Base exception for leaderboard repository failures.
/// {@endtemplate}
abstract class LeaderboardException implements Exception {
/// {@macro leaderboard_exception}
const LeaderboardException(this.error, this.stackTrace);
/// The error that was caught.
final Object error;
/// The Stacktrace associated with the [error].
final StackTrace stackTrace;
}
/// {@template leaderboard_deserialization_exception}
/// Exception thrown when leaderboard data cannot be deserialized in the
/// expected way.
/// {@endtemplate}
class LeaderboardDeserializationException extends LeaderboardException {
/// {@macro leaderboard_deserialization_exception}
const LeaderboardDeserializationException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template fetch_top_10_leaderboard_exception}
/// Exception thrown when failure occurs while fetching top 10 leaderboard.
/// {@endtemplate}
class FetchTop10LeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const FetchTop10LeaderboardException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template fetch_leaderboard_exception}
/// Exception thrown when failure occurs while fetching the leaderboard.
/// {@endtemplate}
class FetchLeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const FetchLeaderboardException(Object error, StackTrace stackTrace)
: 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}
class AddLeaderboardEntryException extends LeaderboardException {
/// {@macro add_leaderboard_entry_exception}
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);
}

@ -1,20 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
/// {@template leaderboard_ranking}
/// Contains [ranking] for a single [LeaderboardEntryData] and the number of
/// players the [ranking] is [outOf].
/// {@endtemplate}
class LeaderboardRanking extends Equatable {
/// {@macro leaderboard_ranking}
const LeaderboardRanking({required this.ranking, required this.outOf});
/// Place ranking by score for a [LeaderboardEntryData].
final int ranking;
/// Number of [LeaderboardEntryData]s at the time of score entry.
final int outOf;
@override
List<Object> get props => [ranking, outOf];
}

@ -1,2 +1,2 @@
export 'exceptions.dart';
export 'leaderboard_entry_data.dart'; export 'leaderboard_entry_data.dart';
export 'leaderboard_ranking.dart';

@ -153,7 +153,6 @@ void main() {
character: CharacterType.dash, character: CharacterType.dash,
); );
const entryDocumentId = 'id$entryScore'; const entryDocumentId = 'id$entryScore';
final ranking = LeaderboardRanking(ranking: 3, outOf: 4);
setUp(() { setUp(() {
leaderboardRepository = LeaderboardRepository(firestore); leaderboardRepository = LeaderboardRepository(firestore);
@ -165,13 +164,12 @@ void main() {
final queryDocumentSnapshot = MockQueryDocumentSnapshot(); final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{ when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash', 'character': 'dash',
'username': 'user$score', 'playerInitials': 'AAA',
'score': score 'score': score
}); });
when(() => queryDocumentSnapshot.id).thenReturn('id$score'); when(() => queryDocumentSnapshot.id).thenReturn('id$score');
return queryDocumentSnapshot; return queryDocumentSnapshot;
}).toList(); }).toList();
when(() => firestore.collection('leaderboard')) when(() => firestore.collection('leaderboard'))
.thenAnswer((_) => collectionReference); .thenAnswer((_) => collectionReference);
when(() => collectionReference.add(any())) when(() => collectionReference.add(any()))
@ -184,19 +182,29 @@ void main() {
}); });
test( test(
'adds leaderboard entry and returns player ranking when ' 'throws FetchLeaderboardException '
'firestore operations succeed', () async { 'when querying the leaderboard fails', () {
final rankingResult = when(() => firestore.collection('leaderboard')).thenThrow(Exception());
await leaderboardRepository.addLeaderboardEntry(leaderboardEntry); expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
expect(rankingResult, equals(ranking)); throwsA(isA<FetchLeaderboardException>()),
);
}); });
test( test(
'throws AddLeaderboardEntryException when Exception occurs ' 'saves the new score if the existing leaderboard '
'when trying to add entry to firestore', () async { 'has less than 10 scores', () async {
when(() => firestore.collection('leaderboard')).thenThrow(Exception()); await leaderboardRepository.addLeaderboardEntry(leaderboardEntry);
verify(
() => collectionReference.add(leaderboardEntry.toJson()),
).called(1);
});
test(
'throws AddLeaderboardEntryException '
'when adding a new entry fails', () async {
when(() => collectionReference.add(leaderboardEntry.toJson()))
.thenThrow(Exception('oops'));
expect( expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
throwsA(isA<AddLeaderboardEntryException>()), throwsA(isA<AddLeaderboardEntryException>()),
@ -204,26 +212,160 @@ void main() {
}); });
test( test(
'throws FetchPlayerRankingException when Exception occurs ' 'does nothing if there are more than 10 scores in the leaderboard '
'when trying to retrieve information from firestore', () async { 'and the new score is smaller than the top 10', () async {
when(() => collectionReference.orderBy('score', descending: true)) final leaderboardScores = [
.thenThrow(Exception()); 10000,
9500,
9000,
8500,
8000,
7500,
7000,
6500,
6000,
5500,
5000
];
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');
return queryDocumentSnapshot;
}).toList();
when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots);
expect( await leaderboardRepository.addLeaderboardEntry(leaderboardEntry);
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), verifyNever(
throwsA(isA<FetchPlayerRankingException>()), () => collectionReference.add(leaderboardEntry.toJson()),
); );
}); });
test( test(
'throws FetchPlayerRankingException when score cannot be found ' 'throws DeleteLeaderboardException '
'in firestore leaderboard data', () async { 'when deleting scores outside the top 10 fails', () async {
when(() => documentReference.id).thenReturn('nonexistentDocumentId'); 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( expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), () => leaderboardRepository.addLeaderboardEntry(newScore),
throwsA(isA<FetchPlayerRankingException>()), 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();
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())
.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);
}); });
}); });

@ -1,19 +0,0 @@
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:test/test.dart';
void main() {
group('LeaderboardRanking', () {
test('can be instantiated', () {
const leaderboardRanking = LeaderboardRanking(ranking: 1, outOf: 1);
expect(leaderboardRanking, isNotNull);
});
test('supports value equality.', () {
const leaderboardRanking = LeaderboardRanking(ranking: 1, outOf: 1);
const leaderboardRanking2 = LeaderboardRanking(ranking: 1, outOf: 1);
expect(leaderboardRanking, equals(leaderboardRanking2));
});
});
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save