Merge branch 'main' into fix/flipper-movement

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

@ -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:

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

@ -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,18 @@
name: share_repository
on:
push:
paths:
- "packages/share_repository/**"
- ".github/workflows/share_repository.yaml"
pull_request:
paths:
- "packages/share_repository/**"
- ".github/workflows/share_repository.yaml"
jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
working_directory: packages/share_repository

3
.gitignore vendored

@ -131,6 +131,3 @@ app.*.map.json
test/.test_runner.dart test/.test_runner.dart
web/__/firebase/init.js web/__/firebase/init.js
# Application exceptions
!/packages/pinball_components/assets/images/flutter_sign_post.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -11,8 +11,9 @@ 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/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/landing/landing.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
class App extends StatelessWidget { class App extends StatelessWidget {
@ -34,20 +35,17 @@ class App extends StatelessWidget {
RepositoryProvider.value(value: _leaderboardRepository), RepositoryProvider.value(value: _leaderboardRepository),
RepositoryProvider.value(value: _pinballAudio), RepositoryProvider.value(value: _pinballAudio),
], ],
child: MaterialApp( child: BlocProvider(
title: 'I/O Pinball', create: (context) => CharacterThemeCubit(),
theme: ThemeData( child: const MaterialApp(
appBarTheme: const AppBarTheme(color: Color(0xFF13B9FF)), title: 'I/O Pinball',
colorScheme: ColorScheme.fromSwatch( localizationsDelegates: [
accentColor: const Color(0xFF13B9FF), AppLocalizations.delegate,
), GlobalMaterialLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: PinballGamePage(),
), ),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const LandingPage(),
), ),
); );
} }

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

@ -11,14 +11,10 @@ class GameBloc extends Bloc<GameEvent, GameState> {
GameBloc() : super(const GameState.initial()) { GameBloc() : super(const GameState.initial()) {
on<BallLost>(_onBallLost); on<BallLost>(_onBallLost);
on<Scored>(_onScored); on<Scored>(_onScored);
on<BonusLetterActivated>(_onBonusLetterActivated); on<BonusActivated>(_onBonusActivated);
on<DashNestActivated>(_onDashNestActivated);
on<SparkyTurboChargeActivated>(_onSparkyTurboChargeActivated); on<SparkyTurboChargeActivated>(_onSparkyTurboChargeActivated);
} }
static const bonusWord = 'GOOGLE';
static const bonusWordScore = 10000;
void _onBallLost(BallLost event, Emitter emit) { void _onBallLost(BallLost event, Emitter emit) {
emit(state.copyWith(balls: state.balls - 1)); emit(state.copyWith(balls: state.balls - 1));
} }
@ -29,54 +25,12 @@ class GameBloc extends Bloc<GameEvent, GameState> {
} }
} }
void _onBonusLetterActivated(BonusLetterActivated event, Emitter emit) { void _onBonusActivated(BonusActivated event, Emitter emit) {
final newBonusLetters = [ emit(
...state.activatedBonusLetters, state.copyWith(
event.letterIndex, bonusHistory: [...state.bonusHistory, event.bonus],
]; ),
);
final achievedBonus = newBonusLetters.length == bonusWord.length;
if (achievedBonus) {
emit(
state.copyWith(
activatedBonusLetters: [],
bonusHistory: [
...state.bonusHistory,
GameBonus.word,
],
),
);
add(const Scored(points: bonusWordScore));
} else {
emit(
state.copyWith(activatedBonusLetters: newBonusLetters),
);
}
}
void _onDashNestActivated(DashNestActivated event, Emitter emit) {
final newNests = {
...state.activatedDashNests,
event.nestId,
};
final achievedBonus = newNests.length == 3;
if (achievedBonus) {
emit(
state.copyWith(
balls: state.balls + 1,
activatedDashNests: {},
bonusHistory: [
...state.bonusHistory,
GameBonus.dashNest,
],
),
);
} else {
emit(
state.copyWith(activatedDashNests: newNests),
);
}
} }
Future<void> _onSparkyTurboChargeActivated( Future<void> _onSparkyTurboChargeActivated(

@ -33,26 +33,13 @@ class Scored extends GameEvent {
List<Object?> get props => [points]; List<Object?> get props => [points];
} }
class BonusLetterActivated extends GameEvent { class BonusActivated extends GameEvent {
const BonusLetterActivated(this.letterIndex) const BonusActivated(this.bonus);
: assert(
letterIndex < GameBloc.bonusWord.length,
'Index must be smaller than the length of the word',
);
final int letterIndex; final GameBonus bonus;
@override @override
List<Object?> get props => [letterIndex]; List<Object?> get props => [bonus];
}
class DashNestActivated extends GameEvent {
const DashNestActivated(this.nestId);
final String nestId;
@override
List<Object?> get props => [nestId];
} }
class SparkyTurboChargeActivated extends GameEvent { class SparkyTurboChargeActivated extends GameEvent {

@ -4,15 +4,20 @@ part of 'game_bloc.dart';
/// Defines bonuses that a player can gain during a PinballGame. /// Defines bonuses that a player can gain during a PinballGame.
enum GameBonus { enum GameBonus {
/// Bonus achieved when the user activate all of the bonus /// Bonus achieved when the ball activates all Google letters.
/// letters on the board, forming the bonus word. googleWord,
word,
/// Bonus achieved when the user activates all dash nest bumpers. /// Bonus achieved when the user activates all dash nest bumpers.
dashNest, dashNest,
/// 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}
@ -23,17 +28,13 @@ class GameState extends Equatable {
const GameState({ const GameState({
required this.score, required this.score,
required this.balls, required this.balls,
required this.activatedBonusLetters,
required this.bonusHistory, required this.bonusHistory,
required this.activatedDashNests,
}) : 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(balls >= 0, "Number of balls can't be negative");
const GameState.initial() const GameState.initial()
: score = 0, : score = 0,
balls = 3, balls = 3,
activatedBonusLetters = const [],
activatedDashNests = const {},
bonusHistory = const []; bonusHistory = const [];
/// The current score of the game. /// The current score of the game.
@ -44,12 +45,6 @@ class GameState extends Equatable {
/// When the number of balls is 0, the game is over. /// When the number of balls is 0, the game is over.
final int balls; final int balls;
/// Active bonus letters.
final List<int> activatedBonusLetters;
/// Active dash nests.
final Set<String> activatedDashNests;
/// 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;
@ -57,15 +52,9 @@ class GameState extends Equatable {
/// Determines when the game is over. /// Determines when the game is over.
bool get isGameOver => balls == 0; bool get isGameOver => balls == 0;
/// Shortcut method to check if the given [i]
/// is activated.
bool isLetterActivated(int i) => activatedBonusLetters.contains(i);
GameState copyWith({ GameState copyWith({
int? score, int? score,
int? balls, int? balls,
List<int>? activatedBonusLetters,
Set<String>? activatedDashNests,
List<GameBonus>? bonusHistory, List<GameBonus>? bonusHistory,
}) { }) {
assert( assert(
@ -76,9 +65,6 @@ class GameState extends Equatable {
return GameState( return GameState(
score: score ?? this.score, score: score ?? this.score,
balls: balls ?? this.balls, balls: balls ?? this.balls,
activatedBonusLetters:
activatedBonusLetters ?? this.activatedBonusLetters,
activatedDashNests: activatedDashNests ?? this.activatedDashNests,
bonusHistory: bonusHistory ?? this.bonusHistory, bonusHistory: bonusHistory ?? this.bonusHistory,
); );
} }
@ -87,8 +73,6 @@ class GameState extends Equatable {
List<Object?> get props => [ List<Object?> get props => [
score, score,
balls, balls,
activatedBonusLetters,
activatedDashNests,
bonusHistory, bonusHistory,
]; ];
} }

@ -0,0 +1,29 @@
// 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';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template alien_zone}
/// Area positioned below [Spaceship] where the [Ball]
/// can bounce off [AlienBumper]s.
/// {@endtemplate}
class AlienZone extends Blueprint {
/// {@macro alien_zone}
AlienZone()
: super(
components: [
AlienBumper.a(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(-32.52, -9.1),
AlienBumper.b(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(-22.89, -17.35),
],
);
}

@ -23,7 +23,7 @@ class Board extends Component {
final dino = ChromeDino() final dino = ChromeDino()
..initialPosition = Vector2( ..initialPosition = Vector2(
BoardDimensions.bounds.center.dx + 25, BoardDimensions.bounds.center.dx + 25,
BoardDimensions.bounds.center.dy + 10, BoardDimensions.bounds.center.dy - 10,
); );
await addAll([ await addAll([
@ -42,7 +42,7 @@ class Board extends Component {
// TODO(alestiago): Consider renaming once entire Board is defined. // TODO(alestiago): Consider renaming once entire Board is defined.
class _BottomGroup extends Component { class _BottomGroup extends Component {
/// {@macro bottom_group} /// {@macro bottom_group}
_BottomGroup(); _BottomGroup() : super(priority: RenderPriority.bottomGroup);
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
@ -77,17 +77,17 @@ class _BottomGroupSide extends Component {
final flipper = ControlledFlipper( final flipper = ControlledFlipper(
side: _side, side: _side,
)..initialPosition = Vector2((11.8 * direction) + centerXAdjustment, -43.6); )..initialPosition = Vector2((11.8 * direction) + centerXAdjustment, 43.6);
final baseboard = Baseboard(side: _side) final baseboard = Baseboard(side: _side)
..initialPosition = Vector2( ..initialPosition = Vector2(
(25.58 * direction) + centerXAdjustment, (25.58 * direction) + centerXAdjustment,
-28.69, 28.69,
); );
final kicker = Kicker( final kicker = Kicker(
side: _side, side: _side,
)..initialPosition = Vector2( )..initialPosition = Vector2(
(22.4 * direction) + centerXAdjustment, (22.4 * direction) + centerXAdjustment,
-25, 25,
); );
await addAll([flipper, baseboard, kicker]); await addAll([flipper, baseboard, kicker]);

@ -1,208 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame_bloc/flame_bloc.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 bonus_word}
/// Loads all [BonusLetter]s to compose a [BonusWord].
/// {@endtemplate}
class BonusWord extends Component
with BlocComponent<GameBloc, GameState>, HasGameRef<PinballGame> {
/// {@macro bonus_word}
BonusWord({required Vector2 position}) : _position = position;
final Vector2 _position;
@override
bool listenWhen(GameState? previousState, GameState newState) {
return (previousState?.bonusHistory.length ?? 0) <
newState.bonusHistory.length &&
newState.bonusHistory.last == GameBonus.word;
}
@override
void onNewState(GameState state) {
if (state.bonusHistory.last == GameBonus.word) {
gameRef.audio.googleBonus();
final letters = children.whereType<BonusLetter>().toList();
for (var i = 0; i < letters.length; i++) {
final letter = letters[i];
letter
..isEnabled = false
..add(
SequenceEffect(
[
ColorEffect(
i.isOdd
? BonusLetter._activeColor
: BonusLetter._disableColor,
const Offset(0, 1),
EffectController(duration: 0.25),
),
ColorEffect(
i.isOdd
? BonusLetter._disableColor
: BonusLetter._activeColor,
const Offset(0, 1),
EffectController(duration: 0.25),
),
],
repeatCount: 4,
)..onFinishCallback = () {
letter
..isEnabled = true
..add(
ColorEffect(
BonusLetter._disableColor,
const Offset(0, 1),
EffectController(duration: 0.25),
),
);
},
);
}
}
}
@override
Future<void> onLoad() async {
await super.onLoad();
final offsets = [
Vector2(-12.92, -1.82),
Vector2(-8.33, 0.65),
Vector2(-2.88, 1.75),
];
offsets.addAll(
offsets.reversed
.map(
(offset) => Vector2(-offset.x, offset.y),
)
.toList(),
);
assert(offsets.length == GameBloc.bonusWord.length, 'Invalid positions');
final letters = <BonusLetter>[];
for (var i = 0; i < GameBloc.bonusWord.length; i++) {
letters.add(
BonusLetter(
letter: GameBloc.bonusWord[i],
index: i,
)..initialPosition = _position + offsets[i],
);
}
await addAll(letters);
}
}
/// {@template bonus_letter}
/// [BodyType.static] sensor component, part of a word bonus,
/// which will activate its letter after contact with a [Ball].
/// {@endtemplate}
class BonusLetter extends BodyComponent<PinballGame>
with BlocComponent<GameBloc, GameState>, InitialPosition {
/// {@macro bonus_letter}
BonusLetter({
required String letter,
required int index,
}) : _letter = letter,
_index = index {
paint = Paint()..color = _disableColor;
}
/// The size of the [BonusLetter].
static final size = Vector2.all(3.7);
static const _activeColor = Colors.green;
static const _disableColor = Colors.red;
final String _letter;
final int _index;
/// Indicates if a [BonusLetter] can be activated on [Ball] contact.
///
/// It is disabled whilst animating and enabled again once the animation
/// completes. The animation is triggered when [GameBonus.word] is
/// awarded.
bool isEnabled = true;
@override
Future<void> onLoad() async {
await super.onLoad();
await add(
TextComponent(
position: Vector2(-1, -1),
text: _letter,
textRenderer: TextPaint(
style: const TextStyle(fontSize: 2, color: Colors.white),
),
),
);
}
@override
Body createBody() {
final shape = CircleShape()..radius = size.x / 2;
final fixtureDef = FixtureDef(shape)..isSensor = true;
final bodyDef = BodyDef()
..position = initialPosition
..userData = this
..type = BodyType.static;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
@override
bool listenWhen(GameState? previousState, GameState newState) {
final wasActive = previousState?.isLetterActivated(_index) ?? false;
final isActive = newState.isLetterActivated(_index);
return wasActive != isActive;
}
@override
void onNewState(GameState state) {
final isActive = state.isLetterActivated(_index);
add(
ColorEffect(
isActive ? _activeColor : _disableColor,
const Offset(0, 1),
EffectController(duration: 0.25),
),
);
}
/// Activates this [BonusLetter], if it's not already activated.
void activate() {
final isActive = state?.isLetterActivated(_index) ?? false;
if (!isActive) {
gameRef.read<GameBloc>().add(BonusLetterActivated(_index));
}
}
}
/// Triggers [BonusLetter.activate] method when a [BonusLetter] and a [Ball]
/// come in contact.
class BonusLetterBallContactCallback
extends ContactCallback<Ball, BonusLetter> {
@override
void begin(Ball ball, BonusLetter bonusLetter, Contact contact) {
if (bonusLetter.isEnabled) {
bonusLetter.activate();
}
}
}

@ -1,7 +1,7 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:pinball/flame/flame.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// Adds helpers methods to Flame's [Camera] /// Adds helpers methods to Flame's [Camera]
extension CameraX on Camera { extension CameraX on Camera {

@ -1,13 +1,13 @@
export 'alien_zone.dart';
export 'board.dart'; export 'board.dart';
export 'bonus_word.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_sparky_computer.dart'; export 'controlled_plunger.dart';
export 'flutter_forest.dart'; export 'flutter_forest/flutter_forest.dart';
export 'game_flow_controller.dart'; export 'game_flow_controller.dart';
export 'plunger.dart'; export 'google_word/google_word.dart';
export 'score_effect_controller.dart'; export 'launcher.dart';
export 'score_points.dart'; export 'scoring_behavior.dart';
export 'sparky_fire_zone.dart'; export 'sparky_fire_zone.dart';
export 'wall.dart'; export 'wall.dart';

@ -1,9 +1,9 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_forge2d/forge2d_game.dart'; import 'package:flame_forge2d/forge2d_game.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/flame/flame.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';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_theme/pinball_theme.dart';
/// {@template controlled_ball} /// {@template controlled_ball}
@ -15,9 +15,11 @@ class ControlledBall extends Ball with Controls<BallController> {
/// When a launched [Ball] is lost, it will decrease the [GameState.balls] /// When a launched [Ball] is lost, it will decrease the [GameState.balls]
/// count, and a new [Ball] is spawned. /// 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;
} }
/// {@template bonus_ball} /// {@template bonus_ball}
@ -26,14 +28,16 @@ class ControlledBall extends Ball with Controls<BallController> {
/// When a bonus [Ball] is lost, the [GameState.balls] doesn't change. /// 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;
} }
/// [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 = DebugBallController(this);
priority = RenderPriority.ballOnBoard;
} }
} }
@ -45,10 +49,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;
} }
@ -58,13 +60,15 @@ class BallController extends ComponentController<Ball>
Future<void> turboCharge() async { Future<void> turboCharge() async {
gameRef.read<GameBloc>().add(const SparkyTurboChargeActivated()); gameRef.read<GameBloc>().add(const SparkyTurboChargeActivated());
// TODO(allisonryan0002): adjust delay to match animation duration once
// given animations.
component.stop(); component.stop();
await Future<void>.delayed(const Duration(seconds: 1)); // TODO(alestiago): Refactor this hard coded duration once the following is
component // merged:
..resume() // https://github.com/flame-engine/flame/pull/1564
..boost(Vector2(200, -500)); await Future<void>.delayed(
const Duration(milliseconds: 2583),
);
component.resume();
await component.boost(Vector2(40, 110));
} }
@override @override

@ -1,7 +1,9 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pinball/flame/flame.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template controlled_flipper} /// {@template controlled_flipper}
/// A [Flipper] with a [FlipperController] attached. /// A [Flipper] with a [FlipperController] attached.
@ -19,7 +21,7 @@ class ControlledFlipper extends Flipper with Controls<FlipperController> {
/// A [ComponentController] that controls a [Flipper]s movement. /// A [ComponentController] that controls a [Flipper]s movement.
/// {@endtemplate} /// {@endtemplate}
class FlipperController extends ComponentController<Flipper> class FlipperController extends ComponentController<Flipper>
with KeyboardHandler { with KeyboardHandler, BlocComponent<GameBloc, GameState> {
/// {@macro flipper_controller} /// {@macro flipper_controller}
FlipperController(Flipper flipper) FlipperController(Flipper flipper)
: _keys = flipper.side.flipperKeys, : _keys = flipper.side.flipperKeys,
@ -35,6 +37,7 @@ class FlipperController extends ComponentController<Flipper>
RawKeyEvent event, RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed, Set<LogicalKeyboardKey> keysPressed,
) { ) {
if (state?.isGameOver ?? false) return true;
if (!_keys.contains(event.logicalKey)) return true; if (!_keys.contains(event.logicalKey)) return true;
if (event is RawKeyDownEvent) { if (event is RawKeyDownEvent) {

@ -0,0 +1,52 @@
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flutter/services.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template controlled_plunger}
/// A [Plunger] with a [PlungerController] attached.
/// {@endtemplate}
class ControlledPlunger extends Plunger with Controls<PlungerController> {
/// {@macro controlled_plunger}
ControlledPlunger({required double compressionDistance})
: super(compressionDistance: compressionDistance) {
controller = PlungerController(this);
}
}
/// {@template plunger_controller}
/// A [ComponentController] that controls a [Plunger]s movement.
/// {@endtemplate}
class PlungerController extends ComponentController<Plunger>
with KeyboardHandler, BlocComponent<GameBloc, GameState> {
/// {@macro plunger_controller}
PlungerController(Plunger plunger) : super(plunger);
/// The [LogicalKeyboardKey]s that will control the [Flipper].
///
/// [onKeyEvent] method listens to when one of these keys is pressed.
static const List<LogicalKeyboardKey> _keys = [
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.space,
LogicalKeyboardKey.keyS,
];
@override
bool onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
if (state?.isGameOver ?? false) return true;
if (!_keys.contains(event.logicalKey)) return true;
if (event is RawKeyDownEvent) {
component.pull();
} else if (event is RawKeyUpEvent) {
component.release();
}
return false;
}
}

@ -1,84 +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/flame/flame.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template controlled_sparky_computer}
/// [SparkyComputer] with a [SparkyComputerController] attached.
/// {@endtemplate}
class ControlledSparkyComputer extends SparkyComputer
with Controls<SparkyComputerController>, HasGameRef<PinballGame> {
/// {@macro controlled_sparky_computer}
ControlledSparkyComputer() {
controller = SparkyComputerController(this);
}
@override
void build(Forge2DGame _) {
addContactCallback(SparkyTurboChargeSensorBallContactCallback());
final sparkyTurboChargeSensor = SparkyTurboChargeSensor()
..initialPosition = Vector2(-13, 49.8);
add(sparkyTurboChargeSensor);
super.build(_);
}
}
/// {@template sparky_computer_controller}
/// Controller attached to a [SparkyComputer] that handles its game related
/// logic.
/// {@endtemplate}
// TODO(allisonryan0002): listen for turbo charge game bonus and animate Sparky.
class SparkyComputerController
extends ComponentController<ControlledSparkyComputer> {
/// {@macro sparky_computer_controller}
SparkyComputerController(ControlledSparkyComputer controlledComputer)
: super(controlledComputer);
}
/// {@template sparky_turbo_charge_sensor}
/// Small sensor body used to detect when a ball has entered the
/// [SparkyComputer] with the [SparkyTurboChargeSensorBallContactCallback].
/// {@endtemplate}
@visibleForTesting
class SparkyTurboChargeSensor extends BodyComponent with InitialPosition {
/// {@macro sparky_turbo_charge_sensor}
SparkyTurboChargeSensor() {
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);
}
}
/// {@template sparky_turbo_charge_sensor_ball_contact_callback}
/// Turbo charges the [Ball] on contact with [SparkyTurboChargeSensor].
/// {@endtemplate}
@visibleForTesting
class SparkyTurboChargeSensorBallContactCallback
extends ContactCallback<SparkyTurboChargeSensor, ControlledBall> {
/// {@macro sparky_turbo_charge_sensor_ball_contact_callback}
SparkyTurboChargeSensorBallContactCallback();
@override
void begin(
SparkyTurboChargeSensor sparkyTurboChargeSensor,
ControlledBall ball,
_,
) {
ball.controller.turboCharge();
}
}

@ -1,157 +0,0 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/flame/flame.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.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 [BigDashNestBumper] releases a new [Ball].
/// {@endtemplate}
// TODO(alestiago): Make a [Blueprint] once [Blueprint] inherits from
// [Component].
class FlutterForest extends Component with Controls<_FlutterForestController> {
/// {@macro flutter_forest}
FlutterForest() {
controller = _FlutterForestController(this);
}
@override
Future<void> onLoad() async {
await super.onLoad();
final signPost = FlutterSignPost()..initialPosition = Vector2(8.35, 58.3);
final bigNest = _ControlledBigDashNestBumper(
id: 'big_nest_bumper',
)..initialPosition = Vector2(18.55, 59.35);
final smallLeftNest = _ControlledSmallDashNestBumper.a(
id: 'small_nest_bumper_a',
)..initialPosition = Vector2(8.95, 51.95);
final smallRightNest = _ControlledSmallDashNestBumper.b(
id: 'small_nest_bumper_b',
)..initialPosition = Vector2(23.3, 46.75);
await addAll([
signPost,
smallLeftNest,
smallRightNest,
bigNest,
]);
}
}
class _FlutterForestController extends ComponentController<FlutterForest>
with BlocComponent<GameBloc, GameState>, HasGameRef<PinballGame> {
_FlutterForestController(FlutterForest flutterForest) : super(flutterForest);
@override
Future<void> onLoad() async {
await super.onLoad();
gameRef.addContactCallback(_ControlledDashNestBumperBallContactCallback());
}
@override
bool listenWhen(GameState? previousState, GameState newState) {
return (previousState?.bonusHistory.length ?? 0) <
newState.bonusHistory.length &&
newState.bonusHistory.last == GameBonus.dashNest;
}
@override
void onNewState(GameState state) {
super.onNewState(state);
gameRef.add(
ControlledBall.bonus(theme: gameRef.theme)
..initialPosition = Vector2(17.2, 52.7),
);
}
}
class _ControlledBigDashNestBumper extends BigDashNestBumper
with Controls<DashNestBumperController>, ScorePoints {
_ControlledBigDashNestBumper({required String id}) : super() {
controller = DashNestBumperController(this, id: id);
}
@override
int get points => 20;
}
class _ControlledSmallDashNestBumper extends SmallDashNestBumper
with Controls<DashNestBumperController>, ScorePoints {
_ControlledSmallDashNestBumper.a({required String id}) : super.a() {
controller = DashNestBumperController(this, id: id);
}
_ControlledSmallDashNestBumper.b({required String id}) : super.b() {
controller = DashNestBumperController(this, id: id);
}
@override
int get points => 10;
}
/// {@template dash_nest_bumper_controller}
/// Controls a [DashNestBumper].
/// {@endtemplate}
@visibleForTesting
class DashNestBumperController extends ComponentController<DashNestBumper>
with BlocComponent<GameBloc, GameState>, HasGameRef<PinballGame> {
/// {@macro dash_nest_bumper_controller}
DashNestBumperController(
DashNestBumper dashNestBumper, {
required this.id,
}) : super(dashNestBumper);
/// Unique identifier for the controlled [DashNestBumper].
///
/// Used to identify [DashNestBumper]s in [GameState.activatedDashNests].
final String id;
@override
bool listenWhen(GameState? previousState, GameState newState) {
final wasActive = previousState?.activatedDashNests.contains(id) ?? false;
final isActive = newState.activatedDashNests.contains(id);
return wasActive != isActive;
}
@override
void onNewState(GameState state) {
super.onNewState(state);
if (state.activatedDashNests.contains(id)) {
component.activate();
} else {
component.deactivate();
}
}
/// Registers when a [DashNestBumper] is hit by a [Ball].
///
/// Triggered by [_ControlledDashNestBumperBallContactCallback].
void hit() {
gameRef.read<GameBloc>().add(DashNestActivated(id));
}
}
/// Listens when a [Ball] bounces bounces against a [DashNestBumper].
class _ControlledDashNestBumperBallContactCallback
extends ContactCallback<Controls<DashNestBumperController>, Ball> {
@override
void begin(
Controls<DashNestBumperController> controlledDashNestBumper,
Ball _,
Contact __,
) {
controlledDashNestBumper.controller.hit();
}
}

@ -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,49 @@
// 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';
/// {@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 {
/// {@macro flutter_forest}
FlutterForest()
: super(
priority: RenderPriority.flutterForest,
children: [
Signpost(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(8.35, -58.3),
DashNestBumper.main(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(18.55, -59.35),
DashNestBumper.a(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(8.95, -51.95),
DashNestBumper.b(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(23.3, -46.75),
DashAnimatronic()..position = Vector2(20, -66),
FlutterForestBonusBehavior(),
],
);
/// Creates a [FlutterForest] without any children.
///
/// This can be used for testing [FlutterForest]'s behaviors in isolation.
@visibleForTesting
FlutterForest.test();
}

@ -1,8 +1,8 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/flame/flame.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';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template game_flow_controller} /// {@template game_flow_controller}
/// A [Component] that controls the game over and game restart logic /// A [Component] that controls the game over and game restart logic
@ -28,7 +28,12 @@ class GameFlowController extends ComponentController<PinballGame>
/// Puts the game on a game over state /// Puts the game on a game over state
void gameOver() { void gameOver() {
component.firstChild<Backboard>()?.gameOverMode(); // TODO(erickzanardo): implement score submission and "navigate" to the
// next page
component.firstChild<Backboard>()?.gameOverMode(
score: state?.score ?? 0,
characterIconPath: component.characterTheme.leaderboardIcon.keyName,
);
component.firstChild<CameraController>()?.focusOnBackboard(); component.firstChild<CameraController>()?.focusOnBackboard();
} }

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

@ -0,0 +1,21 @@
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/components/components.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets;
import 'package:pinball_flame/pinball_flame.dart';
/// {@template launcher}
/// A [Blueprint] which creates the [Plunger], [RocketSpriteComponent] and
/// [LaunchRamp].
/// {@endtemplate}
class Launcher extends Blueprint {
/// {@macro launcher}
Launcher()
: super(
components: [
ControlledPlunger(compressionDistance: 14)
..initialPosition = Vector2(40.7, 38),
RocketSpriteComponent()..position = Vector2(43, 62),
],
blueprints: [LaunchRamp()],
);
}

@ -1,172 +0,0 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/services.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets;
/// {@template plunger}
/// [Plunger] serves as a spring, that shoots the ball on the right side of the
/// playfield.
///
/// [Plunger] ignores gravity so the player controls its downward [_pull].
/// {@endtemplate}
class Plunger extends BodyComponent with KeyboardHandler, InitialPosition {
/// {@macro plunger}
Plunger({
required this.compressionDistance,
// TODO(ruimiguel): set to priority +1 over LaunchRamp once all priorities
// are fixed.
}) : super(priority: 0);
/// Distance the plunger can lower.
final double compressionDistance;
@override
Body createBody() {
final shape = PolygonShape()
..setAsBox(
1.35,
0.5,
Vector2.zero(),
BoardDimensions.perspectiveAngle,
);
final fixtureDef = FixtureDef(shape)..density = 80;
final bodyDef = BodyDef()
..position = initialPosition
..userData = this
..type = BodyType.dynamic
..gravityScale = 0;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
/// Set a constant downward velocity on the [Plunger].
void _pull() {
body.linearVelocity = Vector2(0, -7);
}
/// Set an upward velocity on the [Plunger].
///
/// The velocity's magnitude depends on how far the [Plunger] has been pulled
/// from its original [initialPosition].
void _release() {
final velocity = (initialPosition.y - body.position.y) * 5;
body.linearVelocity = Vector2(0, velocity);
}
@override
bool onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
final keys = [
LogicalKeyboardKey.space,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.keyS,
];
if (!keys.contains(event.logicalKey)) return true;
if (event is RawKeyDownEvent) {
_pull();
} else if (event is RawKeyUpEvent) {
_release();
}
return false;
}
/// Anchors the [Plunger] to the [PrismaticJoint] that controls its vertical
/// motion.
Future<void> _anchorToJoint() async {
final anchor = PlungerAnchor(plunger: this);
await add(anchor);
final jointDef = PlungerAnchorPrismaticJointDef(
plunger: this,
anchor: anchor,
);
world.createJoint(
PrismaticJoint(jointDef)..setLimits(-compressionDistance, 0),
);
}
@override
Future<void> onLoad() async {
await super.onLoad();
await _anchorToJoint();
renderBody = false;
await _loadSprite();
}
Future<void> _loadSprite() async {
final sprite = await gameRef.loadSprite(
Assets.images.components.plunger.path,
);
await add(
SpriteComponent(
sprite: sprite,
size: Vector2(5.5, 40),
anchor: Anchor.center,
position: Vector2(2, 19),
angle: -0.033,
),
);
}
}
/// {@template plunger_anchor}
/// [JointAnchor] positioned below a [Plunger].
/// {@endtemplate}
class PlungerAnchor extends JointAnchor {
/// {@macro plunger_anchor}
PlungerAnchor({
required Plunger plunger,
}) {
initialPosition = Vector2(
0,
-plunger.compressionDistance,
);
}
@override
Body createBody() {
final bodyDef = BodyDef()
..position = initialPosition
..type = BodyType.static;
return world.createBody(bodyDef);
}
}
/// {@template plunger_anchor_prismatic_joint_def}
/// [PrismaticJointDef] between a [Plunger] and an [JointAnchor] with motion on
/// the vertical axis.
///
/// The [Plunger] is constrained vertically between its starting position and
/// the [JointAnchor]. The [JointAnchor] must be below the [Plunger].
/// {@endtemplate}
class PlungerAnchorPrismaticJointDef extends PrismaticJointDef {
/// {@macro plunger_anchor_prismatic_joint_def}
PlungerAnchorPrismaticJointDef({
required Plunger plunger,
required PlungerAnchor anchor,
}) {
initialize(
plunger.body,
anchor.body,
plunger.body.position + anchor.body.position,
Vector2(18.6, BoardDimensions.bounds.height),
);
enableLimit = true;
lowerTranslation = double.negativeInfinity;
enableMotor = true;
motorSpeed = 1000;
maxMotorForce = motorSpeed;
collideConnected = true;
}
}

@ -1,45 +0,0 @@
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/flame/flame.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template score_effect_controller}
/// A [ComponentController] responsible for adding [ScoreText]s
/// on the game screen when the user earns points.
/// {@endtemplate}
class ScoreEffectController extends ComponentController<PinballGame>
with BlocComponent<GameBloc, GameState> {
/// {@macro score_effect_controller}
ScoreEffectController(PinballGame component) : super(component);
int _lastScore = 0;
final _random = Random();
double _noise() {
return _random.nextDouble() * 5 * (_random.nextBool() ? -1 : 1);
}
@override
bool listenWhen(GameState? previousState, GameState newState) {
return previousState?.score != newState.score;
}
@override
void onNewState(GameState state) {
final newScore = state.score - _lastScore;
_lastScore = state.score;
component.add(
ScoreText(
text: newScore.toString(),
position: Vector2(
_noise(),
_noise() + (-BoardDimensions.bounds.topCenter.dy + 10),
),
),
);
}
}

@ -1,43 +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 _,
ScorePoints scorePoints,
Contact __,
) {
_gameRef.read<GameBloc>().add(
Scored(points: scorePoints.points),
);
_gameRef.audio.score();
}
}

@ -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.add(
ScoreText(
text: _points.toString(),
position: other.body.position,
),
);
}
}

@ -1,103 +1,71 @@
// ignore_for_file: avoid_renaming_method_parameters // ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/flame/flame.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';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template sparky_fire_zone} /// {@template sparky_fire_zone}
/// Area positioned at the top left of the [Board] where the [Ball] /// Area positioned at the top left of the [Board] where the [Ball]
/// can bounce off [SparkyBumper]s. /// can bounce off [SparkyBumper]s.
/// ///
/// When a [Ball] hits [SparkyBumper]s, they toggle between activated and /// When a [Ball] hits [SparkyBumper]s, the bumper animates.
/// deactivated states.
/// {@endtemplate} /// {@endtemplate}
class SparkyFireZone extends Component with HasGameRef<PinballGame> { class SparkyFireZone extends Blueprint {
/// {@macro sparky_fire_zone} /// {@macro sparky_fire_zone}
SparkyFireZone(); SparkyFireZone()
: super(
@override components: [
Future<void> onLoad() async { SparkyBumper.a(
await super.onLoad(); children: [
ScoringBehavior(points: 20),
gameRef.addContactCallback(_ControlledSparkyBumperBallContactCallback()); ],
)..initialPosition = Vector2(-22.9, -41.65),
final lowerLeftBumper = ControlledSparkyBumper.a() SparkyBumper.b(
..initialPosition = Vector2(-23.15, 41.65); children: [
final upperLeftBumper = ControlledSparkyBumper.b() ScoringBehavior(points: 20),
..initialPosition = Vector2(-21.25, 58.15); ],
final rightBumper = ControlledSparkyBumper.c() )..initialPosition = Vector2(-21.25, -57.9),
..initialPosition = Vector2(-3.56, 53.051); SparkyBumper.c(
children: [
await addAll([ ScoringBehavior(points: 20),
lowerLeftBumper, ],
upperLeftBumper, )..initialPosition = Vector2(-3.3, -52.55),
rightBumper, SparkyComputerSensor()..initialPosition = Vector2(-13, -49.8),
]); SparkyAnimatronic()..position = Vector2(-13.8, -58.2),
} ],
blueprints: [
SparkyComputer(),
],
);
} }
/// {@template controlled_sparky_bumper} /// {@template sparky_computer_sensor}
/// [SparkyBumper] with [_SparkyBumperController] attached. /// Small sensor body used to detect when a ball has entered the
/// [SparkyComputer].
/// {@endtemplate} /// {@endtemplate}
@visibleForTesting class SparkyComputerSensor extends BodyComponent
class ControlledSparkyBumper extends SparkyBumper with InitialPosition, ContactCallbacks {
with Controls<_SparkyBumperController>, ScorePoints { /// {@macro sparky_computer_sensor}
///{@macro controlled_sparky_bumper} SparkyComputerSensor() : super(renderBody: false);
ControlledSparkyBumper.a() : super.a() {
controller = _SparkyBumperController(this);
}
///{@macro controlled_sparky_bumper}
ControlledSparkyBumper.b() : super.b() {
controller = _SparkyBumperController(this);
}
///{@macro controlled_sparky_bumper}
ControlledSparkyBumper.c() : super.c() {
controller = _SparkyBumperController(this);
}
@override @override
int get points => 20; Body createBody() {
} final shape = CircleShape()..radius = 0.1;
final fixtureDef = FixtureDef(shape, isSensor: true);
/// {@template sparky_bumper_controller} final bodyDef = BodyDef(
/// Controls a [SparkyBumper]. position: initialPosition,
/// {@endtemplate} userData: this,
class _SparkyBumperController extends ComponentController<SparkyBumper> );
with HasGameRef<PinballGame> { return world.createBody(bodyDef)..createFixture(fixtureDef);
/// {@macro sparky_bumper_controller}
_SparkyBumperController(ControlledSparkyBumper controlledSparkyBumper)
: super(controlledSparkyBumper);
/// Flag for activated state of the [SparkyBumper].
///
/// Used to toggle [SparkyBumper]s' state between activated and deactivated.
bool isActivated = false;
/// Registers when a [SparkyBumper] is hit by a [Ball].
void hit() {
if (isActivated) {
component.deactivate();
} else {
component.activate();
}
isActivated = !isActivated;
} }
}
/// Listens when a [Ball] bounces bounces against a [SparkyBumper].
class _ControlledSparkyBumperBallContactCallback
extends ContactCallback<Controls<_SparkyBumperController>, Ball> {
@override @override
void begin( void beginContact(Object other, Contact contact) {
Controls<_SparkyBumperController> controlledSparkyBumper, super.beginContact(other, contact);
Ball _, if (other is! ControlledBall) return;
Contact __,
) { other.controller.turboCharge();
controlledSparkyBumper.controller.hit(); gameRef.firstChild<SparkyAnimatronic>()?.playing = true;
} }
} }

@ -39,44 +39,22 @@ class Wall extends BodyComponent {
} }
} }
/// Create top, left, and right [Wall]s for the game board.
List<Wall> createBoundaries(Forge2DGame game) {
final topLeft = BoardDimensions.bounds.topLeft.toVector2() + Vector2(18.6, 0);
final bottomRight = BoardDimensions.bounds.bottomRight.toVector2();
final topRight =
BoardDimensions.bounds.topRight.toVector2() - Vector2(18.6, 0);
final bottomLeft = BoardDimensions.bounds.bottomLeft.toVector2();
return [
Wall(start: topLeft, end: topRight),
Wall(start: topRight, end: bottomRight),
Wall(start: topLeft, end: bottomLeft),
];
}
/// {@template bottom_wall} /// {@template bottom_wall}
/// [Wall] located at the bottom of the board. /// [Wall] located at the bottom of the board.
/// ///
/// Collisions with [BottomWall] are listened by
/// [BottomWallBallContactCallback].
/// {@endtemplate} /// {@endtemplate}
class BottomWall extends Wall { class BottomWall extends Wall with ContactCallbacks {
/// {@macro bottom_wall} /// {@macro bottom_wall}
BottomWall() BottomWall()
: super( : super(
start: BoardDimensions.bounds.bottomLeft.toVector2(), start: BoardDimensions.bounds.bottomLeft.toVector2(),
end: BoardDimensions.bounds.bottomRight.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 @override
void begin(ControlledBall ball, BottomWall wall, Contact contact) { void beginContact(Object other, Contact contact) {
ball.controller.lost(); super.beginContact(other, contact);
if (other is! ControlledBall) return;
other.controller.lost();
} }
} }

@ -1,61 +1,110 @@
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart'; import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_components/pinball_components.dart' as components; import 'package:pinball_components/pinball_components.dart' as components;
import 'package:pinball_theme/pinball_theme.dart' hide Assets;
/// Add methods to help loading and caching game assets. /// Add methods to help loading and caching game assets.
extension PinballGameAssetsX on PinballGame { extension PinballGameAssetsX on PinballGame {
/// Returns a list of assets to be loaded /// Returns a list of assets to be loaded
List<Future> preLoadAssets() { List<Future> preLoadAssets() {
const dashTheme = DashTheme();
const sparkyTheme = SparkyTheme();
const androidTheme = AndroidTheme();
const dinoTheme = DinoTheme();
return [ return [
images.load(components.Assets.images.ball.keyName), images.load(components.Assets.images.ball.ball.keyName),
images.load(components.Assets.images.flutterSignPost.keyName), images.load(components.Assets.images.ball.flameEffect.keyName),
images.load(components.Assets.images.signpost.inactive.keyName),
images.load(components.Assets.images.signpost.active1.keyName),
images.load(components.Assets.images.signpost.active2.keyName),
images.load(components.Assets.images.signpost.active3.keyName),
images.load(components.Assets.images.flipper.left.keyName), images.load(components.Assets.images.flipper.left.keyName),
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.keyName),
images.load(components.Assets.images.kicker.right.keyName), images.load(components.Assets.images.kicker.right.keyName),
images.load(components.Assets.images.slingshot.leftUpper.keyName), images.load(components.Assets.images.slingshot.upper.keyName),
images.load(components.Assets.images.slingshot.leftLower.keyName), images.load(components.Assets.images.slingshot.lower.keyName),
images.load(components.Assets.images.slingshot.rightUpper.keyName),
images.load(components.Assets.images.slingshot.rightLower.keyName),
images.load(components.Assets.images.launchRamp.ramp.keyName), images.load(components.Assets.images.launchRamp.ramp.keyName),
images.load( images.load(
components.Assets.images.launchRamp.foregroundRailing.keyName, components.Assets.images.launchRamp.foregroundRailing.keyName,
), ),
images.load(
components.Assets.images.launchRamp.backgroundRailing.keyName,
),
images.load(components.Assets.images.dino.dinoLandTop.keyName), images.load(components.Assets.images.dino.dinoLandTop.keyName),
images.load(components.Assets.images.dino.dinoLandBottom.keyName), images.load(components.Assets.images.dino.dinoLandBottom.keyName),
images.load(components.Assets.images.dashBumper.a.active.keyName), images.load(components.Assets.images.dash.animatronic.keyName),
images.load(components.Assets.images.dashBumper.a.inactive.keyName), images.load(components.Assets.images.dash.bumper.a.active.keyName),
images.load(components.Assets.images.dashBumper.b.active.keyName), images.load(components.Assets.images.dash.bumper.a.inactive.keyName),
images.load(components.Assets.images.dashBumper.b.inactive.keyName), images.load(components.Assets.images.dash.bumper.b.active.keyName),
images.load(components.Assets.images.dashBumper.main.active.keyName), images.load(components.Assets.images.dash.bumper.b.inactive.keyName),
images.load(components.Assets.images.dashBumper.main.inactive.keyName), images.load(components.Assets.images.dash.bumper.main.active.keyName),
images.load(components.Assets.images.dash.bumper.main.inactive.keyName),
images.load(components.Assets.images.plunger.plunger.keyName),
images.load(components.Assets.images.plunger.rocket.keyName),
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.spaceship.saucer.keyName), images.load(components.Assets.images.spaceship.saucer.keyName),
images.load(components.Assets.images.spaceship.bridge.keyName), images.load(components.Assets.images.spaceship.bridge.keyName),
images.load(components.Assets.images.spaceship.ramp.main.keyName), images.load(components.Assets.images.spaceship.ramp.boardOpening.keyName),
images.load(
components.Assets.images.spaceship.ramp.railingForeground.keyName,
),
images.load( images.load(
components.Assets.images.spaceship.ramp.railingBackground.keyName, components.Assets.images.spaceship.ramp.railingBackground.keyName,
), ),
images.load(components.Assets.images.spaceship.ramp.main.keyName),
images
.load(components.Assets.images.spaceship.ramp.arrow.inactive.keyName),
images.load( images.load(
components.Assets.images.spaceship.ramp.railingForeground.keyName, components.Assets.images.spaceship.ramp.arrow.active1.keyName,
),
images.load(
components.Assets.images.spaceship.ramp.arrow.active2.keyName,
),
images.load(
components.Assets.images.spaceship.ramp.arrow.active3.keyName,
),
images.load(
components.Assets.images.spaceship.ramp.arrow.active4.keyName,
),
images.load(
components.Assets.images.spaceship.ramp.arrow.active5.keyName,
), ),
images.load(components.Assets.images.spaceship.rail.main.keyName), images.load(components.Assets.images.spaceship.rail.main.keyName),
images.load(components.Assets.images.spaceship.rail.foreground.keyName), images.load(components.Assets.images.spaceship.rail.foreground.keyName),
images.load(components.Assets.images.alienBumper.a.active.keyName),
images.load(components.Assets.images.alienBumper.a.inactive.keyName),
images.load(components.Assets.images.alienBumper.b.active.keyName),
images.load(components.Assets.images.alienBumper.b.inactive.keyName),
images.load(components.Assets.images.chromeDino.mouth.keyName), images.load(components.Assets.images.chromeDino.mouth.keyName),
images.load(components.Assets.images.chromeDino.head.keyName), images.load(components.Assets.images.chromeDino.head.keyName),
images.load(components.Assets.images.sparky.computer.base.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.bumper.a.active.keyName), images.load(components.Assets.images.sparky.computer.base.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.inactive.keyName),
images.load(components.Assets.images.sparky.bumper.a.active.keyName),
images.load(components.Assets.images.sparky.bumper.b.active.keyName), images.load(components.Assets.images.sparky.bumper.b.active.keyName),
images.load(components.Assets.images.sparky.bumper.b.inactive.keyName), images.load(components.Assets.images.sparky.bumper.b.inactive.keyName),
images.load(components.Assets.images.sparky.bumper.c.active.keyName), images.load(components.Assets.images.sparky.bumper.c.active.keyName),
images.load(components.Assets.images.sparky.bumper.c.inactive.keyName), images.load(components.Assets.images.sparky.bumper.c.inactive.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.keyName),
images.load(components.Assets.images.googleWord.letter2.keyName),
images.load(components.Assets.images.googleWord.letter3.keyName),
images.load(components.Assets.images.googleWord.letter4.keyName),
images.load(components.Assets.images.googleWord.letter5.keyName),
images.load(components.Assets.images.googleWord.letter6.keyName),
images.load(components.Assets.images.backboard.display.keyName),
images.load(dashTheme.leaderboardIcon.keyName),
images.load(sparkyTheme.leaderboardIcon.keyName),
images.load(androidTheme.leaderboardIcon.keyName),
images.load(dinoTheme.leaderboardIcon.keyName),
images.load(Assets.images.components.background.path), images.load(Assets.images.components.background.path),
]; ];
} }

@ -2,14 +2,16 @@
import 'dart:async'; import 'dart:async';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
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:pinball/flame/flame.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/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' hide Assets;
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart' hide Assets;
class PinballGame extends Forge2DGame class PinballGame extends Forge2DGame
@ -18,7 +20,7 @@ class PinballGame extends Forge2DGame
HasKeyboardHandlerComponents, HasKeyboardHandlerComponents,
Controls<_GameBallsController> { Controls<_GameBallsController> {
PinballGame({ PinballGame({
required this.theme, required this.characterTheme,
required this.audio, required this.audio,
}) { }) {
images.prefix = ''; images.prefix = '';
@ -28,7 +30,10 @@ class PinballGame extends Forge2DGame
/// Identifier of the play button overlay /// Identifier of the play button overlay
static const playButtonOverlay = 'play_button'; static const playButtonOverlay = 'play_button';
final PinballTheme theme; @override
Color backgroundColor() => Colors.transparent;
final CharacterTheme characterTheme;
final PinballAudio audio; final PinballAudio audio;
@ -36,65 +41,43 @@ class PinballGame extends Forge2DGame
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
_addContactCallbacks();
unawaited(add(ScoreEffectController(this)));
unawaited(add(gameFlowController = GameFlowController(this))); unawaited(add(gameFlowController = GameFlowController(this)));
unawaited(add(CameraController(this))); unawaited(add(CameraController(this)));
unawaited(add(Backboard(position: Vector2(0, -88)))); unawaited(add(Backboard.waiting(position: Vector2(0, -88))));
await _addGameBoundaries(); // TODO(allisonryan0002): banish Wall and Board classes in later PR.
await add(BottomWall());
unawaited(addFromBlueprint(Boundaries())); unawaited(addFromBlueprint(Boundaries()));
unawaited(addFromBlueprint(LaunchRamp())); unawaited(addFromBlueprint(LaunchRamp()));
unawaited(addFromBlueprint(ControlledSparkyComputer()));
final plunger = Plunger(compressionDistance: 29)
..initialPosition = Vector2(38, -19);
await add(plunger);
final launcher = Launcher();
unawaited(addFromBlueprint(launcher));
unawaited(add(Board())); unawaited(add(Board()));
unawaited(add(SparkyFireZone())); await addFromBlueprint(AlienZone());
await addFromBlueprint(SparkyFireZone());
unawaited(addFromBlueprint(Slingshots())); unawaited(addFromBlueprint(Slingshots()));
unawaited(addFromBlueprint(DinoWalls())); unawaited(addFromBlueprint(DinoWalls()));
unawaited(_addBonusWord());
unawaited(addFromBlueprint(SpaceshipRamp())); unawaited(addFromBlueprint(SpaceshipRamp()));
unawaited( unawaited(
addFromBlueprint( addFromBlueprint(
Spaceship( Spaceship(
position: Vector2(-26.5, 28.5), position: Vector2(-26.5, -28.5),
), ),
), ),
); );
unawaited( unawaited(addFromBlueprint(SpaceshipRail()));
addFromBlueprint(
SpaceshipRail(),
),
);
controller.attachTo(plunger);
await super.onLoad();
}
void _addContactCallbacks() {
addContactCallback(BallScorePointsCallback(this));
addContactCallback(BottomWallBallContactCallback());
addContactCallback(BonusLetterBallContactCallback());
}
Future<void> _addGameBoundaries() async {
await add(BottomWall());
createBoundaries(this).forEach(add);
}
Future<void> _addBonusWord() async {
await add( await add(
BonusWord( GoogleWord(
position: Vector2( position: Vector2(
BoardDimensions.bounds.center.dx - 3.07, BoardDimensions.bounds.center.dx - 4.1,
BoardDimensions.bounds.center.dy - 2.4, BoardDimensions.bounds.center.dy + 1.8,
), ),
), ),
); );
controller.attachTo(launcher.components.whereType<Plunger>().first);
await super.onLoad();
} }
} }
@ -126,10 +109,10 @@ class _GameBallsController extends ComponentController<PinballGame>
void _spawnBall() { void _spawnBall() {
final ball = ControlledBall.launch( final ball = ControlledBall.launch(
theme: gameRef.theme, characterTheme: gameRef.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.add(ball);
} }
@ -142,12 +125,12 @@ class _GameBallsController extends ComponentController<PinballGame>
} }
} }
class DebugPinballGame extends PinballGame with TapDetector { class DebugPinballGame extends PinballGame with FPSCounter, TapDetector {
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 = _DebugGameBallsController(this);
@ -157,6 +140,7 @@ class DebugPinballGame extends PinballGame with TapDetector {
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
await _loadBackground(); await _loadBackground();
await add(_DebugInformation());
} }
// TODO(alestiago): Move to PinballGame once we have the real background // TODO(alestiago): Move to PinballGame once we have the real background
@ -171,7 +155,7 @@ class DebugPinballGame extends PinballGame with TapDetector {
anchor: Anchor.center, anchor: Anchor.center,
) )
..position = Vector2(0, -7.8) ..position = Vector2(0, -7.8)
..priority = -2; ..priority = RenderPriority.background;
await add(spriteComponent); await add(spriteComponent);
} }
@ -199,3 +183,35 @@ class _DebugGameBallsController extends _GameBallsController {
return noBallsLeft && canBallRespawn; return noBallsLeft && canBallRespawn;
} }
} }
class _DebugInformation extends Component with HasGameRef<DebugPinballGame> {
_DebugInformation() : super(priority: RenderPriority.debugInfo);
@override
PositionType get positionType => PositionType.widget;
final _debugTextPaint = TextPaint(
style: const TextStyle(
color: Colors.green,
fontSize: 10,
),
);
final _debugBackgroundPaint = Paint()..color = Colors.white;
@override
void render(Canvas canvas) {
final debugText = [
'FPS: ${gameRef.fps().toStringAsFixed(1)}',
'BALLS: ${gameRef.descendants().whereType<ControlledBall>().length}',
].join(' | ');
final height = _debugTextPaint.measureTextHeight(debugText);
final position = Vector2(0, gameRef.camera.canvasSize.y - height);
canvas.drawRect(
position & Vector2(gameRef.camera.canvasSize.x, height),
_debugBackgroundPaint,
);
_debugTextPaint.render(canvas, debugText, position);
}
}

@ -5,45 +5,25 @@ 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_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_theme/pinball_theme.dart';
class PinballGamePage extends StatelessWidget { class PinballGamePage extends StatelessWidget {
const PinballGamePage({ const PinballGamePage({
Key? key, Key? key,
required this.theme, this.isDebugMode = kDebugMode,
required this.game,
}) : super(key: key); }) : super(key: key);
final PinballTheme theme; final bool isDebugMode;
final PinballGame game;
static Route route({ static Route route({
required PinballTheme theme,
bool isDebugMode = kDebugMode, bool isDebugMode = kDebugMode,
}) { }) {
return MaterialPageRoute<void>( return MaterialPageRoute<void>(
builder: (context) { builder: (context) {
final audio = context.read<PinballAudio>(); return PinballGamePage(
isDebugMode: isDebugMode,
final game = isDebugMode
? DebugPinballGame(theme: theme, audio: audio)
: PinballGame(theme: theme, audio: audio);
final pinballAudio = context.read<PinballAudio>();
final loadables = [
...game.preLoadAssets(),
pinballAudio.load(),
];
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => GameBloc()),
BlocProvider(
create: (_) => AssetsManagerCubit(loadables)..load(),
),
],
child: PinballGamePage(theme: theme, game: game),
); );
}, },
); );
@ -51,7 +31,31 @@ class PinballGamePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PinballGameView(game: game); final characterTheme =
context.read<CharacterThemeCubit>().state.characterTheme;
final audio = context.read<PinballAudio>();
final pinballAudio = context.read<PinballAudio>();
final game = isDebugMode
? DebugPinballGame(characterTheme: characterTheme, audio: audio)
: PinballGame(characterTheme: characterTheme, audio: audio);
final loadables = [
...game.preLoadAssets(),
pinballAudio.load(),
...BonusAnimation.loadAssets(),
];
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => StartGameBloc(game: game)),
BlocProvider(create: (_) => GameBloc()),
BlocProvider(
create: (_) => AssetsManagerCubit(loadables)..load(),
),
],
child: PinballGameView(game: game),
);
} }
} }
@ -65,17 +69,54 @@ class PinballGameView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loadingProgress = context.watch<AssetsManagerCubit>().state.progress; final isLoading = context.select(
(AssetsManagerCubit bloc) => bloc.state.progress != 1,
);
if (loadingProgress != 1) { return Scaffold(
return Scaffold( backgroundColor: Colors.blue,
body: Center( body: isLoading
child: Text( ? const _PinballGameLoadingView()
loadingProgress.toString(), : PinballGameLoadedView(game: game),
), );
}
}
class _PinballGameLoadingView extends StatelessWidget {
const _PinballGameLoadingView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final loadingProgress = context.select(
(AssetsManagerCubit bloc) => bloc.state.progress,
);
return Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: LinearProgressIndicator(
color: Colors.white,
value: loadingProgress,
), ),
); ),
} );
}
}
@visibleForTesting
class PinballGameLoadedView extends StatelessWidget {
const PinballGameLoadedView({
Key? key,
required this.game,
}) : super(key: key);
final PinballGame game;
@override
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: [
@ -95,10 +136,12 @@ class PinballGameView 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(),
), ),
], ],
); );

@ -0,0 +1,156 @@
import 'package:flame/flame.dart';
import 'package:flame/sprite.dart';
import 'package:flutter/material.dart' hide Image;
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template bonus_animation}
/// [Widget] that displays bonus animations.
/// {@endtemplate}
class BonusAnimation extends StatefulWidget {
/// {@macro bonus_animation}
const BonusAnimation._(
String imagePath, {
VoidCallback? onCompleted,
Key? key,
}) : _imagePath = imagePath,
_onCompleted = onCompleted,
super(key: key);
/// [Widget] that displays the dash nest animation.
BonusAnimation.dashNest({
Key? key,
VoidCallback? onCompleted,
}) : this._(
Assets.images.bonusAnimation.dashNest.keyName,
onCompleted: onCompleted,
key: key,
);
/// [Widget] that displays the sparky turbo charge animation.
BonusAnimation.sparkyTurboCharge({
Key? key,
VoidCallback? onCompleted,
}) : this._(
Assets.images.bonusAnimation.sparkyTurboCharge.keyName,
onCompleted: onCompleted,
key: key,
);
/// [Widget] that displays the dino chomp animation.
BonusAnimation.dinoChomp({
Key? key,
VoidCallback? onCompleted,
}) : this._(
Assets.images.bonusAnimation.dinoChomp.keyName,
onCompleted: onCompleted,
key: key,
);
/// [Widget] that displays the android spaceship animation.
BonusAnimation.androidSpaceship({
Key? key,
VoidCallback? onCompleted,
}) : this._(
Assets.images.bonusAnimation.androidSpaceship.keyName,
onCompleted: onCompleted,
key: key,
);
/// [Widget] that displays the google word animation.
BonusAnimation.googleWord({
Key? key,
VoidCallback? onCompleted,
}) : this._(
Assets.images.bonusAnimation.googleWord.keyName,
onCompleted: onCompleted,
key: key,
);
final String _imagePath;
final VoidCallback? _onCompleted;
/// Returns a list of assets to be loaded for animations.
static List<Future> loadAssets() {
Flame.images.prefix = '';
return [
Flame.images.load(Assets.images.bonusAnimation.dashNest.keyName),
Flame.images.load(Assets.images.bonusAnimation.sparkyTurboCharge.keyName),
Flame.images.load(Assets.images.bonusAnimation.dinoChomp.keyName),
Flame.images.load(Assets.images.bonusAnimation.androidSpaceship.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
Widget build(BuildContext context) {
final spriteSheet = SpriteSheet.fromColumnsAndRows(
image: Flame.images.fromCache(widget._imagePath),
columns: 8,
rows: 9,
);
animation = spriteSheet.createAnimation(
row: 0,
stepTime: 1 / 24,
to: spriteSheet.rows * spriteSheet.columns,
loop: false,
);
Future<void>.delayed(
Duration(seconds: animation.totalDuration().ceil()),
() {
if (shouldRunBuildCallback) {
widget._onCompleted?.call();
}
},
);
controller = SpriteAnimationController(
animation: animation,
vsync: this,
)..forward();
return SizedBox(
width: double.infinity,
height: double.infinity,
child: SpriteAnimationWidget(
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/theme/app_colors.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.balls] 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(
onComplete: () {
if (mounted) {
setState(() => showAnimation = false);
}
},
)
: const ScoreView(),
), ),
Wrap( ),
direction: Axis.vertical, ),
children: [ );
for (var i = 0; i < state.balls; i++) }
const Padding( }
padding: EdgeInsets.only(top: 6, right: 6),
child: CircleAvatar( class _ScoreViewDecoration extends StatelessWidget {
radius: 8, const _ScoreViewDecoration({
backgroundColor: Colors.black, Key? key,
), required this.child,
), }) : super(key: key);
],
final Widget child;
@override
Widget build(BuildContext context) {
const radius = BorderRadius.all(Radius.circular(12));
const boardWidth = 5.0;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(
color: AppColors.white,
width: boardWidth,
),
image: DecorationImage(
fit: BoxFit.cover,
image: AssetImage(
Assets.images.score.miniScoreBackground.path,
), ),
], ),
), ),
child: Padding(
padding: const EdgeInsets.all(boardWidth - 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,6 +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/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
@ -18,9 +19,27 @@ class PlayButtonOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return Center( return Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: _game.gameFlowController.start, onPressed: () {
_game.gameFlowController.start();
showDialog<void>(
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,70 @@
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/theme/theme.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;
// TODO(arturplaczek): refactor when GameState handle balls and rounds and
// select state.rounds property instead of state.ball
final balls = context.select((GameBloc bloc) => bloc.state.balls);
return Row(
children: [
Text(
l10n.rounds,
style: AppTextStyle.subtitle1.copyWith(
color: AppColors.orange,
),
),
const SizedBox(width: 8),
Row(
children: [
RoundIndicator(isActive: balls >= 1),
RoundIndicator(isActive: balls >= 2),
RoundIndicator(isActive: balls >= 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 ? AppColors.orange : AppColors.orange.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,86 @@
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/theme/theme.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: AppTextStyle.headline1.copyWith(
color: AppColors.white,
),
);
}
}
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: AppTextStyle.subtitle1.copyWith(
color: AppColors.orange,
),
),
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: AppTextStyle.headline1.copyWith(
color: AppColors.white,
),
);
}
}

@ -1,2 +1,5 @@
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';

@ -3,22 +3,58 @@
/// FlutterGen /// FlutterGen
/// ***************************************************** /// *****************************************************
// ignore_for_file: directives_ordering,unnecessary_import
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class $AssetsImagesGen { class $AssetsImagesGen {
const $AssetsImagesGen(); const $AssetsImagesGen();
$AssetsImagesBonusAnimationGen get bonusAnimation =>
const $AssetsImagesBonusAnimationGen();
$AssetsImagesComponentsGen get components => $AssetsImagesComponentsGen get components =>
const $AssetsImagesComponentsGen(); const $AssetsImagesComponentsGen();
$AssetsImagesScoreGen get score => const $AssetsImagesScoreGen();
}
class $AssetsImagesBonusAnimationGen {
const $AssetsImagesBonusAnimationGen();
/// File path: assets/images/bonus_animation/android_spaceship.png
AssetGenImage get androidSpaceship => const AssetGenImage(
'assets/images/bonus_animation/android_spaceship.png');
/// File path: assets/images/bonus_animation/dash_nest.png
AssetGenImage get dashNest =>
const AssetGenImage('assets/images/bonus_animation/dash_nest.png');
/// File path: assets/images/bonus_animation/dino_chomp.png
AssetGenImage get dinoChomp =>
const AssetGenImage('assets/images/bonus_animation/dino_chomp.png');
/// File path: assets/images/bonus_animation/google_word.png
AssetGenImage get googleWord =>
const AssetGenImage('assets/images/bonus_animation/google_word.png');
/// File path: assets/images/bonus_animation/sparky_turbo_charge.png
AssetGenImage get sparkyTurboCharge => const AssetGenImage(
'assets/images/bonus_animation/sparky_turbo_charge.png');
} }
class $AssetsImagesComponentsGen { class $AssetsImagesComponentsGen {
const $AssetsImagesComponentsGen(); const $AssetsImagesComponentsGen();
/// 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');
AssetGenImage get plunger => }
const AssetGenImage('assets/images/components/plunger.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 'assets.gen.dart';

@ -1,71 +1,83 @@
{ {
"@@locale": "en", "@@locale": "en",
"play": "Play", "play": "Play",
"@play": { "@play": {
"description": "Text displayed on the landing page play button" "description": "Text displayed on the landing page play button"
}, },
"howToPlay": "How to Play", "howToPlay": "How to Play",
"@howToPlay": { "@howToPlay": {
"description": "Text displayed on the landing page how to play button" "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"
}, },
"flipperControls": "Flipper Controls", "flipperControls": "Flipper Controls",
"@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"
}, },
"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"
}, },
"characterSelectionTitle": "Choose your character!", "select": "Select",
"@characterSelectionTitle": { "@select": {
"description": "Title text displayed on the character selection page" "description": "Text displayed on the character selection page select button"
}, },
"gameOver": "Game Over", "characterSelectionTitle": "Choose your character!",
"@gameOver": { "@characterSelectionTitle": {
"description": "Text displayed on the ending dialog when game finishes" "description": "Title text displayed on the character selection page"
}, },
"leaderboard": "Leaderboard", "characterSelectionSubtitle": "Theres no wrong answer",
"@leaderboard": { "@characterSelectionSubtitle": {
"description": "Text displayed on the ending dialog leaderboard button" "description": "Text displayed on the selecting character dialog at game beginning"
}, },
"rank": "Rank", "gameOver": "Game Over",
"@rank": { "@gameOver": {
"description": "Text displayed on the leaderboard page header rank column" "description": "Text displayed on the ending dialog when game finishes"
}, },
"character": "Character", "leaderboard": "Leaderboard",
"@character": { "@leaderboard": {
"description": "Text displayed on the leaderboard page header character column" "description": "Text displayed on the ending dialog leaderboard button"
}, },
"username": "Username", "rank": "Rank",
"@username": { "@rank": {
"description": "Text displayed on the leaderboard page header userName column" "description": "Text displayed on the leaderboard page header rank column"
}, },
"score": "Score", "character": "Character",
"@score": { "@character": {
"description": "Text displayed on the leaderboard page header score column" "description": "Text displayed on the leaderboard page header character column"
}, },
"retry": "Retry", "username": "Username",
"@retry": { "@username": {
"description": "Text displayed on the retry button leaders board page" "description": "Text displayed on the leaderboard page header userName column"
}, },
"addUser": "Add User", "score": "Score",
"@addUser": { "@score": {
"description": "Text displayed on the add user button at ending dialog" "description": "Text displayed on the leaderboard page header score column"
}, },
"error": "Error", "retry": "Retry",
"@error": { "@retry": {
"description": "Text displayed on the ending dialog when there is any error on sending user" "description": "Text displayed on the retry button leaders board page"
}, },
"yourScore": "Your score is", "addUser": "Add User",
"@yourScore": { "@addUser": {
"description": "Text displayed on the ending dialog when game finishes to show the final score" "description": "Text displayed on the add user button at ending dialog"
}, },
"enterInitials": "Enter your initials", "error": "Error",
"@enterInitials": { "@error": {
"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 there is any error on sending user"
} },
"yourScore": "Your score is",
"@yourScore": {
"description": "Text displayed on the ending dialog when game finishes to show the final score"
},
"enterInitials": "Enter your initials",
"@enterInitials": {
"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"
}
} }

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

@ -36,7 +36,7 @@ extension LeaderboardEntryDataX on LeaderboardEntryData {
rank: position.toString(), rank: position.toString(),
playerInitials: playerInitials, playerInitials: playerInitials,
score: score, score: score,
character: character.toTheme.characterAsset, character: character.toTheme.leaderboardIcon,
); );
} }
} }

@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/leaderboard/leaderboard.dart'; import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball/theme/theme.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_theme/pinball_theme.dart';
class LeaderboardPage extends StatelessWidget { class LeaderboardPage extends StatelessWidget {
@ -69,7 +69,7 @@ class LeaderboardView extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
TextButton( TextButton(
onPressed: () => Navigator.of(context).push<void>( onPressed: () => Navigator.of(context).push<void>(
CharacterSelectionPage.route(), CharacterSelectionDialog.route(),
), ),
child: Text(l10n.retry), child: Text(l10n.retry),
), ),

@ -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,15 @@
// 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;
@override
List<Object> get props => [characterTheme];
}

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

@ -2,24 +2,24 @@
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/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/start_game/start_game.dart';
import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_theme/pinball_theme.dart';
class CharacterSelectionPage extends StatelessWidget { class CharacterSelectionDialog extends StatelessWidget {
const CharacterSelectionPage({Key? key}) : super(key: key); const CharacterSelectionDialog({Key? key}) : super(key: key);
static Route route() { static Route route() {
return MaterialPageRoute<void>( return MaterialPageRoute<void>(
builder: (_) => const CharacterSelectionPage(), builder: (_) => const CharacterSelectionDialog(),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (_) => ThemeCubit(), create: (_) => CharacterThemeCubit(),
child: const CharacterSelectionView(), child: const CharacterSelectionView(),
); );
} }
@ -46,11 +46,13 @@ class CharacterSelectionView extends StatelessWidget {
const _CharacterSelectionGridView(), const _CharacterSelectionGridView(),
const SizedBox(height: 20), const SizedBox(height: 20),
TextButton( TextButton(
onPressed: () => Navigator.of(context).push<void>( onPressed: () {
PinballGamePage.route( Navigator.of(context).pop();
theme: context.read<ThemeCubit>().state.theme, showDialog<void>(
), context: context,
), builder: (_) => const HowToPlayDialog(),
);
},
child: Text(l10n.start), child: Text(l10n.start),
), ),
], ],
@ -107,12 +109,14 @@ class CharacterImageButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentCharacterTheme = context.select<ThemeCubit, CharacterTheme>( final currentCharacterTheme =
(cubit) => cubit.state.theme.characterTheme, context.select<CharacterThemeCubit, CharacterTheme>(
(cubit) => cubit.state.characterTheme,
); );
return GestureDetector( return GestureDetector(
onTap: () => context.read<ThemeCubit>().characterSelected(characterTheme), onTap: () =>
context.read<CharacterThemeCubit>().characterSelected(characterTheme),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: (currentCharacterTheme == characterTheme) color: (currentCharacterTheme == characterTheme)
@ -122,7 +126,7 @@ class CharacterImageButton extends StatelessWidget {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: characterTheme.characterAsset.image(), child: characterTheme.icon.image(),
), ),
), ),
); );

@ -0,0 +1,58 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:pinball/game/game.dart';
part 'start_game_event.dart';
part 'start_game_state.dart';
/// {@template start_game_bloc}
/// Bloc that manages the app flow before the game starts.
/// {@endtemplate}
class StartGameBloc extends Bloc<StartGameEvent, StartGameState> {
/// {@macro start_game_bloc}
StartGameBloc({
required PinballGame game,
}) : _game = game,
super(const StartGameState.initial()) {
on<PlayTapped>(_onPlayTapped);
on<CharacterSelected>(_onCharacterSelected);
on<HowToPlayFinished>(_onHowToPlayFinished);
}
final PinballGame _game;
void _onPlayTapped(
PlayTapped event,
Emitter<StartGameState> emit,
) {
_game.gameFlowController.start();
emit(
state.copyWith(
status: StartGameStatus.selectCharacter,
),
);
}
void _onCharacterSelected(
CharacterSelected event,
Emitter<StartGameState> emit,
) {
emit(
state.copyWith(
status: StartGameStatus.howToPlay,
),
);
}
void _onHowToPlayFinished(
HowToPlayFinished event,
Emitter<StartGameState> emit,
) {
emit(
state.copyWith(
status: StartGameStatus.play,
),
);
}
}

@ -0,0 +1,42 @@
part of 'start_game_bloc.dart';
/// {@template start_game_event}
/// Event added during the start game flow.
/// {@endtemplate}
abstract class StartGameEvent extends Equatable {
/// {@macro start_game_event}
const StartGameEvent();
}
/// {@template play_tapped}
/// Play tapped event.
/// {@endtemplate}
class PlayTapped extends StartGameEvent {
/// {@macro play_tapped}
const PlayTapped();
@override
List<Object> get props => [];
}
/// {@template character_selected}
/// Character selected event.
/// {@endtemplate}
class CharacterSelected extends StartGameEvent {
/// {@macro character_selected}
const CharacterSelected();
@override
List<Object> get props => [];
}
/// {@template how_to_play_finished}
/// How to play finished event.
/// {@endtemplate}
class HowToPlayFinished extends StartGameEvent {
/// {@macro how_to_play_finished}
const HowToPlayFinished();
@override
List<Object> get props => [];
}

@ -0,0 +1,44 @@
part of 'start_game_bloc.dart';
/// Defines status of start game flow.
enum StartGameStatus {
/// Initial status.
initial,
/// Selection characters status.
selectCharacter,
/// How to play status.
howToPlay,
/// Play status.
play,
}
/// {@template start_game_state}
/// Represents the state of flow before the game starts.
/// {@endtemplate}
class StartGameState extends Equatable {
/// {@macro start_game_state}
const StartGameState({
required this.status,
});
/// Initial [StartGameState].
const StartGameState.initial() : this(status: StartGameStatus.initial);
/// Status of [StartGameState].
final StartGameStatus status;
/// Creates a copy of [StartGameState].
StartGameState copyWith({
StartGameStatus? status,
}) {
return StartGameState(
status: status ?? this.status,
);
}
@override
List<Object> get props => [status];
}

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

@ -2,42 +2,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/theme/theme.dart';
class LandingPage extends StatelessWidget { class HowToPlayDialog extends StatelessWidget {
const LandingPage({Key? key}) : super(key: key); const HowToPlayDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () => Navigator.of(context).push<void>(
CharacterSelectionPage.route(),
),
child: Text(l10n.play),
),
TextButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) => const _HowToPlayDialog(),
),
child: Text(l10n.howToPlay),
),
],
),
),
);
}
}
class _HowToPlayDialog extends StatelessWidget {
const _HowToPlayDialog({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

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

@ -0,0 +1,15 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
abstract class AppColors {
static const Color white = Color(0xFFFFFFFF);
static const Color darkBlue = Color(0xFF0C32A4);
static const Color orange = Color(0xFFFFEE02);
static const Color blue = Color(0xFF4B94F6);
static const Color transparent = Color(0x00000000);
}

@ -0,0 +1,35 @@
// 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 = FontFamily.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,2 +1,2 @@
export 'cubit/theme_cubit.dart'; export 'app_colors.dart';
export 'view/view.dart'; export 'app_text_style.dart';

@ -72,6 +72,20 @@ class FetchPlayerRankingException extends LeaderboardException {
); );
} }
/// {@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}
@ -152,4 +166,25 @@ class LeaderboardRepository {
throw FetchPlayerRankingException(error, stackTrace); throw FetchPlayerRankingException(error, stackTrace);
} }
} }
/// Determines if the given [initials] are allowed.
Future<bool> areInitialsAllowed({required String initials}) async {
// Initials can only be three uppercase A-Z letters
final initialsRegex = RegExp(r'^[A-Z]{3}$');
if (!initialsRegex.hasMatch(initials)) {
return false;
}
try {
final document = await _firebaseFirestore
.collection('prohibitedInitials')
.doc('list')
.get();
final prohibitedInitials =
document.get('prohibitedInitials') as List<String>;
return !prohibitedInitials.contains(initials);
} on Exception catch (error, stackTrace) {
throw FetchProhibitedInitialsException(error, stackTrace);
}
}
} }

@ -21,6 +21,9 @@ class MockQueryDocumentSnapshot extends Mock
class MockDocumentReference extends Mock class MockDocumentReference extends Mock
implements DocumentReference<Map<String, dynamic>> {} implements DocumentReference<Map<String, dynamic>> {}
class MockDocumentSnapshot extends Mock
implements DocumentSnapshot<Map<String, dynamic>> {}
void main() { void main() {
group('LeaderboardRepository', () { group('LeaderboardRepository', () {
late FirebaseFirestore firestore; late FirebaseFirestore firestore;
@ -223,5 +226,94 @@ void main() {
); );
}); });
}); });
group('areInitialsAllowed', () {
late LeaderboardRepository leaderboardRepository;
late CollectionReference<Map<String, dynamic>> collectionReference;
late DocumentReference<Map<String, dynamic>> documentReference;
late DocumentSnapshot<Map<String, dynamic>> documentSnapshot;
setUp(() async {
collectionReference = MockCollectionReference();
documentReference = MockDocumentReference();
documentSnapshot = MockDocumentSnapshot();
leaderboardRepository = LeaderboardRepository(firestore);
when(() => firestore.collection('prohibitedInitials'))
.thenReturn(collectionReference);
when(() => collectionReference.doc('list'))
.thenReturn(documentReference);
when(() => documentReference.get())
.thenAnswer((_) async => documentSnapshot);
when<dynamic>(() => documentSnapshot.get('prohibitedInitials'))
.thenReturn(['BAD']);
});
test('returns true if initials are three letters and allowed', () async {
final isUsernameAllowedResponse =
await leaderboardRepository.areInitialsAllowed(
initials: 'ABC',
);
expect(
isUsernameAllowedResponse,
isTrue,
);
});
test(
'returns false if initials are shorter than 3 characters',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'AB');
expect(areInitialsAllowedResponse, isFalse);
},
);
test(
'returns false if initials are longer than 3 characters',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'ABCD');
expect(areInitialsAllowedResponse, isFalse);
},
);
test(
'returns false if initials contain a lowercase letter',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'AbC');
expect(areInitialsAllowedResponse, isFalse);
},
);
test(
'returns false if initials contain a special character',
() async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'A@C');
expect(areInitialsAllowedResponse, isFalse);
},
);
test('returns false if initials are forbidden', () async {
final areInitialsAllowedResponse =
await leaderboardRepository.areInitialsAllowed(initials: 'BAD');
expect(areInitialsAllowedResponse, isFalse);
});
test(
'throws FetchProhibitedInitialsException when Exception occurs '
'when trying to retrieve information from firestore',
() async {
when(() => firestore.collection('prohibitedInitials'))
.thenThrow(Exception('oops'));
expect(
() => leaderboardRepository.areInitialsAllowed(initials: 'ABC'),
throwsA(isA<FetchProhibitedInitialsException>()),
);
},
);
});
}); });
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

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

Loading…
Cancel
Save