Merge branch 'main' into feat/dino-mechanics

pull/76/head
Alejandro Santiago 4 years ago committed by GitHub
commit 9f948e0570
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,19 @@
name: pinball_components
on:
push:
paths:
- "packages/pinball_components/**"
- ".github/workflows/pinball_components.yaml"
pull_request:
paths:
- "packages/pinball_components/**"
- ".github/workflows/pinball_components.yaml"
jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
working_directory: packages/pinball_components
coverage_excludes: "lib/gen/*.dart"

@ -29,6 +29,12 @@
"type": "dart",
"program": "lib/main_production.dart",
"args": ["--flavor", "production", "--target", "lib/main_production.dart"]
},
{
"name": "Launch component sandbox",
"request": "launch",
"type": "dart",
"program": "packages/pinball_components/sandbox/lib/main.dart"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

@ -12,20 +12,25 @@ const _attachedErrorMessage = "Can't add to attached Blueprints";
/// A [Blueprint] is a virtual way of grouping [Component]s
/// that are related, but they need to be added directly on
/// the [FlameGame] level.
abstract class Blueprint {
abstract class Blueprint<T extends FlameGame> {
final List<Component> _components = [];
final List<Blueprint> _blueprints = [];
bool _isAttached = false;
/// Called before the the [Component]s managed
/// by this blueprint is added to the [FlameGame]
void build();
void build(T gameRef);
/// Attach the [Component]s built on [build] to the [game]
/// instance
@mustCallSuper
Future<void> attach(FlameGame game) async {
build();
await game.addAll(_components);
Future<void> attach(T game) async {
build(game);
await Future.wait([
game.addAll(_components),
..._blueprints.map(game.addFromBlueprint).toList(),
]);
_isAttached = true;
}
@ -41,13 +46,28 @@ abstract class Blueprint {
_components.add(component);
}
/// Adds a list of [Blueprint]s to this blueprint.
void addAllBlueprints(List<Blueprint> blueprints) {
assert(!_isAttached, _attachedErrorMessage);
_blueprints.addAll(blueprints);
}
/// Adds a single [Blueprint] to this blueprint.
void addBlueprint(Blueprint blueprint) {
assert(!_isAttached, _attachedErrorMessage);
_blueprints.add(blueprint);
}
/// Returns a copy of the components built by this blueprint
List<Component> get components => List.unmodifiable(_components);
/// Returns a copy of the children blueprints
List<Blueprint> get blueprints => List.unmodifiable(_blueprints);
}
/// A [Blueprint] that provides additional
/// structures specific to flame_forge2d
abstract class Forge2DBlueprint extends Blueprint {
abstract class Forge2DBlueprint extends Blueprint<Forge2DGame> {
final List<ContactCallback> _callbacks = [];
/// Adds a single [ContactCallback] to this blueprint
@ -63,13 +83,11 @@ abstract class Forge2DBlueprint extends Blueprint {
}
@override
Future<void> attach(FlameGame game) async {
Future<void> attach(Forge2DGame game) async {
await super.attach(game);
assert(game is Forge2DGame, 'Forge2DBlueprint used outside a Forge2DGame');
for (final callback in _callbacks) {
(game as Forge2DGame).addContactCallback(callback);
game.addContactCallback(callback);
}
}

@ -12,6 +12,7 @@ class GameBloc extends Bloc<GameEvent, GameState> {
on<BallLost>(_onBallLost);
on<Scored>(_onScored);
on<BonusLetterActivated>(_onBonusLetterActivated);
on<DashNestActivated>(_onDashNestActivated);
}
static const bonusWord = 'GOOGLE';
@ -52,4 +53,28 @@ class GameBloc extends Bloc<GameEvent, GameState> {
);
}
}
void _onDashNestActivated(DashNestActivated event, Emitter emit) {
const nestsRequiredForBonus = 3;
final newNests = {
...state.activatedDashNests,
event.nestId,
};
if (newNests.length == nestsRequiredForBonus) {
emit(
state.copyWith(
activatedDashNests: {},
bonusHistory: [
...state.bonusHistory,
GameBonus.dashNest,
],
),
);
} else {
emit(
state.copyWith(activatedDashNests: newNests),
);
}
}
}

@ -45,3 +45,12 @@ class BonusLetterActivated extends GameEvent {
@override
List<Object?> get props => [letterIndex];
}
class DashNestActivated extends GameEvent {
const DashNestActivated(this.nestId);
final String nestId;
@override
List<Object?> get props => [nestId];
}

@ -7,6 +7,10 @@ enum GameBonus {
/// Bonus achieved when the user activate all of the bonus
/// letters on the board, forming the bonus word
word,
/// Bonus achieved when the user activates all of the Dash
/// nests on the board, adding a new ball to the board.
dashNest,
}
/// {@template game_state}
@ -19,6 +23,7 @@ class GameState extends Equatable {
required this.balls,
required this.activatedBonusLetters,
required this.bonusHistory,
required this.activatedDashNests,
}) : assert(score >= 0, "Score can't be negative"),
assert(balls >= 0, "Number of balls can't be negative");
@ -26,6 +31,7 @@ class GameState extends Equatable {
: score = 0,
balls = 3,
activatedBonusLetters = const [],
activatedDashNests = const {},
bonusHistory = const [];
/// The current score of the game.
@ -39,6 +45,9 @@ class GameState extends Equatable {
/// 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
/// PinballGame.
final List<GameBonus> bonusHistory;
@ -57,6 +66,7 @@ class GameState extends Equatable {
int? score,
int? balls,
List<int>? activatedBonusLetters,
Set<String>? activatedDashNests,
List<GameBonus>? bonusHistory,
}) {
assert(
@ -69,6 +79,7 @@ class GameState extends Equatable {
balls: balls ?? this.balls,
activatedBonusLetters:
activatedBonusLetters ?? this.activatedBonusLetters,
activatedDashNests: activatedDashNests ?? this.activatedDashNests,
bonusHistory: bonusHistory ?? this.bonusHistory,
);
}
@ -78,6 +89,7 @@ class GameState extends Equatable {
score,
balls,
activatedBonusLetters,
activatedDashNests,
bonusHistory,
];
}

@ -1,61 +1,40 @@
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template ball}
/// A solid, [BodyType.dynamic] sphere that rolls and bounces along the
/// [PinballGame].
/// {@template ball_blueprint}
/// [Blueprint] which cretes a ball game object
/// {@endtemplate}
class Ball extends BodyComponent<PinballGame> with InitialPosition, Layered {
/// {@macro ball}
Ball() {
// TODO(ruimiguel): while developing Ball can be launched by clicking mouse,
// and default layer is Layer.all. But on final game Ball will be always be
// be launched from Plunger and LauncherRamp will modify it to Layer.board.
// We need to see what happens if Ball appears from other place like nest
// bumper, it will need to explicit change layer to Layer.board then.
layer = Layer.board;
}
class BallBlueprint extends Blueprint<PinballGame> {
/// {@macro ball_blueprint}
BallBlueprint({required this.position});
/// The size of the [Ball]
final Vector2 size = Vector2.all(2);
/// The initial position of the [Ball]
final Vector2 position;
@override
Future<void> onLoad() async {
await super.onLoad();
final sprite = await gameRef.loadSprite(Assets.images.components.ball.path);
final tint = gameRef.theme.characterTheme.ballColor.withOpacity(0.5);
await add(
SpriteComponent(
sprite: sprite,
size: size,
anchor: Anchor.center,
)..tint(tint),
);
}
@override
Body createBody() {
final shape = CircleShape()..radius = size.x / 2;
final fixtureDef = FixtureDef(shape)..density = 1;
final bodyDef = BodyDef()
..position = initialPosition
..userData = this
..type = BodyType.dynamic;
void build(PinballGame gameRef) {
final baseColor = gameRef.theme.characterTheme.ballColor;
final ball = Ball(baseColor: baseColor)..add(BallController());
return world.createBody(bodyDef)..createFixture(fixtureDef);
add(ball..initialPosition = position + Vector2(0, ball.size.y / 2));
}
}
/// {@template ball}
/// A solid, [BodyType.dynamic] sphere that rolls and bounces along the
/// [PinballGame].
/// {@endtemplate}
class BallController extends Component with HasGameRef<PinballGame> {
/// Removes the [Ball] from a [PinballGame]; spawning a new [Ball] if
/// any are left.
///
/// Triggered by [BottomWallBallContactCallback] when the [Ball] falls into
/// a [BottomWall].
void lost() {
shouldRemove = true;
parent?.shouldRemove = true;
final bloc = gameRef.read<GameBloc>()..add(const BallLost());
@ -64,19 +43,13 @@ class Ball extends BodyComponent<PinballGame> with InitialPosition, Layered {
gameRef.spawnBall();
}
}
/// Immediatly and completly [stop]s the ball.
///
/// The [Ball] will no longer be affected by any forces, including it's
/// weight and those emitted from collisions.
void stop() {
body.setType(BodyType.static);
}
/// Allows the [Ball] to be affected by forces.
///
/// If previously [stop]ed, the previous ball's velocity is not kept.
void resume() {
body.setType(BodyType.dynamic);
/// Adds helper methods to the [Ball]
extension BallX on Ball {
/// Returns the controller instance of the ball
// TODO(erickzanardo): Remove the need of an extension.
BallController get controller {
return children.whereType<BallController>().first;
}
}

@ -2,9 +2,10 @@ import 'dart:math' as math;
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template baseboard}
/// Straight, angled board piece to corral the [Ball] towards the [Flipper]s.
/// Wing-shaped board piece to corral the [Ball] towards the [Flipper]s.
/// {@endtemplate}
class Baseboard extends BodyComponent with InitialPosition {
/// {@macro baseboard}
@ -12,41 +13,66 @@ class Baseboard extends BodyComponent with InitialPosition {
required BoardSide side,
}) : _side = side;
/// The width of the [Baseboard].
static const width = 10.0;
/// The height of the [Baseboard].
static const height = 2.0;
/// The size of the [Baseboard].
static final size = Vector2(24.2, 13.5);
/// Whether the [Baseboard] is on the left or right side of the board.
final BoardSide _side;
List<FixtureDef> _createFixtureDefs() {
final fixturesDef = <FixtureDef>[];
final direction = _side.direction;
final arcsAngle = -1.11 * direction;
const arcsRotation = math.pi / 2.08;
final circleShape1 = CircleShape()..radius = Baseboard.height / 2;
circleShape1.position.setValues(
-(Baseboard.width / 2) + circleShape1.radius,
0,
final topCircleShape = CircleShape()..radius = 0.7;
topCircleShape.position.setValues(11.39 * direction, 6.05);
final topCircleFixtureDef = FixtureDef(topCircleShape);
fixturesDef.add(topCircleFixtureDef);
final innerEdgeShape = EdgeShape()
..set(
Vector2(10.86 * direction, 6.45),
Vector2(6.96 * direction, 0.25),
);
final circle1FixtureDef = FixtureDef(circleShape1);
fixturesDef.add(circle1FixtureDef);
final innerEdgeShapeFixtureDef = FixtureDef(innerEdgeShape);
fixturesDef.add(innerEdgeShapeFixtureDef);
final circleShape2 = CircleShape()..radius = Baseboard.height / 2;
circleShape2.position.setValues(
(Baseboard.width / 2) - circleShape2.radius,
0,
final outerEdgeShape = EdgeShape()
..set(
Vector2(11.96 * direction, 5.85),
Vector2(5.48 * direction, -4.85),
);
final circle2FixtureDef = FixtureDef(circleShape2);
fixturesDef.add(circle2FixtureDef);
final outerEdgeShapeFixtureDef = FixtureDef(outerEdgeShape);
fixturesDef.add(outerEdgeShapeFixtureDef);
final rectangle = PolygonShape()
..setAsBoxXY(
(Baseboard.width - Baseboard.height) / 2,
Baseboard.height / 2,
final upperArcShape = ArcShape(
center: Vector2(1.76 * direction, 3.25),
arcRadius: 6.1,
angle: arcsAngle,
rotation: arcsRotation,
);
final upperArcFixtureDefs = FixtureDef(upperArcShape);
fixturesDef.add(upperArcFixtureDefs);
final lowerArcShape = ArcShape(
center: Vector2(1.85 * direction, -2.15),
arcRadius: 4.5,
angle: arcsAngle,
rotation: arcsRotation,
);
final lowerArcFixtureDefs = FixtureDef(lowerArcShape);
fixturesDef.add(lowerArcFixtureDefs);
final bottomRectangle = PolygonShape()
..setAsBox(
7,
2,
Vector2(-5.14 * direction, -4.75),
0,
);
final rectangleFixtureDef = FixtureDef(rectangle);
fixturesDef.add(rectangleFixtureDef);
final bottomRectangleFixtureDef = FixtureDef(bottomRectangle);
fixturesDef.add(bottomRectangleFixtureDef);
return fixturesDef;
}
@ -55,7 +81,7 @@ class Baseboard extends BodyComponent with InitialPosition {
Body createBody() {
// TODO(allisonryan0002): share sweeping angle with flipper when components
// are grouped.
const angle = math.pi / 7;
const angle = math.pi / 5;
final bodyDef = BodyDef()
..position = initialPosition

@ -2,9 +2,8 @@ import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
/// {@template board}
/// The main flat surface of the [PinballGame], where the [Flipper]s,
/// [RoundBumper]s, [Kicker]s are arranged.
/// {entemplate}
/// The main flat surface of the [PinballGame].
/// {endtemplate}
class Board extends Component {
/// {@macro board}
Board();
@ -20,7 +19,7 @@ class Board extends Component {
spacing: 2,
);
final dashForest = _FlutterForest(
final flutterForest = FlutterForest(
position: Vector2(
PinballGame.boardBounds.center.dx + 20,
PinballGame.boardBounds.center.dy + 48,
@ -36,45 +35,8 @@ class Board extends Component {
await addAll([
bottomGroup,
dashForest,
dino,
]);
}
}
/// {@template flutter_forest}
/// Area positioned at the top right of the [Board] where the [Ball]
/// can bounce off [RoundBumper]s.
/// {@endtemplate}
class _FlutterForest extends Component {
/// {@macro flutter_forest}
_FlutterForest({
required this.position,
});
final Vector2 position;
@override
Future<void> onLoad() async {
// TODO(alestiago): adjust positioning once sprites are added.
// TODO(alestiago): Use [NestBumper] instead of [RoundBumper] once provided.
final smallLeftNest = RoundBumper(
radius: 1,
points: 10,
)..initialPosition = position + Vector2(-4.8, 2.8);
final smallRightNest = RoundBumper(
radius: 1,
points: 10,
)..initialPosition = position + Vector2(0.5, -5.5);
final bigNest = RoundBumper(
radius: 2,
points: 20,
)..initialPosition = position;
await addAll([
smallLeftNest,
smallRightNest,
bigNest,
flutterForest,
]);
}
}
@ -135,14 +97,14 @@ class _BottomGroupSide extends Component {
Future<void> onLoad() async {
final direction = _side.direction;
final flipper = Flipper.fromSide(
final flipper = Flipper(
side: _side,
)..initialPosition = _position;
final baseboard = Baseboard(side: _side)
..initialPosition = _position +
Vector2(
(Flipper.size.x * direction) - direction,
Flipper.size.y,
(Baseboard.size.x / 1.6 * direction),
Baseboard.size.y - 2,
);
final kicker = Kicker(
side: _side,

@ -8,6 +8,7 @@ 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].
@ -20,13 +21,9 @@ class BonusWord extends Component with BlocComponent<GameBloc, GameState> {
@override
bool listenWhen(GameState? previousState, GameState newState) {
if ((previousState?.bonusHistory.length ?? 0) <
return (previousState?.bonusHistory.length ?? 0) <
newState.bonusHistory.length &&
newState.bonusHistory.last == GameBonus.word) {
return true;
}
return false;
newState.bonusHistory.last == GameBonus.word;
}
@override
@ -76,18 +73,32 @@ class BonusWord extends Component with BlocComponent<GameBloc, GameState> {
@override
Future<void> onLoad() async {
await super.onLoad();
final letters = GameBloc.bonusWord.split('');
for (var i = 0; i < letters.length; i++) {
unawaited(
add(
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: letters[i],
letter: GameBloc.bonusWord[i],
index: i,
)..initialPosition = _position - Vector2(16 - (i * 6), -30),
),
)..initialPosition = _position + offsets[i],
);
}
await addAll(letters);
}
}
@ -106,8 +117,8 @@ class BonusLetter extends BodyComponent<PinballGame>
paint = Paint()..color = _disableColor;
}
/// The area size of this [BonusLetter].
static final areaSize = Vector2.all(4);
/// The size of the [BonusLetter].
static final size = Vector2.all(3.7);
static const _activeColor = Colors.green;
static const _disableColor = Colors.red;
@ -139,7 +150,7 @@ class BonusLetter extends BodyComponent<PinballGame>
@override
Body createBody() {
final shape = CircleShape()..radius = areaSize.x / 2;
final shape = CircleShape()..radius = size.x / 2;
final fixtureDef = FixtureDef(shape)..isSensor = true;

@ -5,16 +5,13 @@ export 'board_side.dart';
export 'bonus_word.dart';
export 'chrome_dino.dart';
export 'flipper.dart';
export 'initial_position.dart';
export 'flutter_forest.dart';
export 'jetpack_ramp.dart';
export 'joint_anchor.dart';
export 'kicker.dart';
export 'launcher_ramp.dart';
export 'layer.dart';
export 'pathway.dart';
export 'plunger.dart';
export 'ramp_opening.dart';
export 'round_bumper.dart';
export 'score_points.dart';
export 'spaceship.dart';
export 'wall.dart';

@ -3,10 +3,20 @@ import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets;
const _leftFlipperKeys = [
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.keyA,
];
const _rightFlipperKeys = [
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.keyD,
];
/// {@template flipper}
/// A bat, typically found in pairs at the bottom of the board.
@ -15,43 +25,9 @@ import 'package:pinball/gen/assets.gen.dart';
/// {@endtemplate flipper}
class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
/// {@macro flipper}
Flipper._({
Flipper({
required this.side,
required List<LogicalKeyboardKey> keys,
}) : _keys = keys;
Flipper._left()
: this._(
side: BoardSide.left,
keys: [
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.keyA,
],
);
Flipper._right()
: this._(
side: BoardSide.right,
keys: [
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.keyD,
],
);
/// Constructs a [Flipper] from a [BoardSide].
///
/// A [Flipper._right] and [Flipper._left] besides being mirrored
/// horizontally, also have different [LogicalKeyboardKey]s that control them.
factory Flipper.fromSide({
required BoardSide side,
}) {
switch (side) {
case BoardSide.left:
return Flipper._left();
case BoardSide.right:
return Flipper._right();
}
}
}) : _keys = side.isLeft ? _leftFlipperKeys : _rightFlipperKeys;
/// The size of the [Flipper].
static final size = Vector2(12, 2.8);
@ -104,35 +80,29 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
/// Anchors the [Flipper] to the [RevoluteJoint] that controls its arc motion.
Future<void> _anchorToJoint() async {
final anchor = FlipperAnchor(flipper: this);
final anchor = _FlipperAnchor(flipper: this);
await add(anchor);
final jointDef = FlipperAnchorRevoluteJointDef(
final jointDef = _FlipperAnchorRevoluteJointDef(
flipper: this,
anchor: anchor,
);
// TODO(alestiago): Remove casting once the following is closed:
// https://github.com/flame-engine/forge2d/issues/36
final joint = world.createJoint(jointDef) as RevoluteJoint;
final joint = _FlipperJoint(jointDef)..create(world);
// FIXME(erickzanardo): when mounted the initial position is not fully
// reached.
unawaited(
mounted.whenComplete(
() => FlipperAnchorRevoluteJointDef.unlock(joint, side),
),
mounted.whenComplete(joint.unlock),
);
}
List<FixtureDef> _createFixtureDefs() {
final fixturesDef = <FixtureDef>[];
final isLeft = side.isLeft;
final direction = side.direction;
final bigCircleShape = CircleShape()..radius = 1.75;
bigCircleShape.position.setValues(
isLeft
? -(size.x / 2) + bigCircleShape.radius
: (size.x / 2) - bigCircleShape.radius,
((size.x / 2) * direction) + (bigCircleShape.radius * -direction),
0,
);
final bigCircleFixtureDef = FixtureDef(bigCircleShape);
@ -140,15 +110,13 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
final smallCircleShape = CircleShape()..radius = 0.9;
smallCircleShape.position.setValues(
isLeft
? (size.x / 2) - smallCircleShape.radius
: -(size.x / 2) + smallCircleShape.radius,
((size.x / 2) * -direction) + (smallCircleShape.radius * direction),
0,
);
final smallCircleFixtureDef = FixtureDef(smallCircleShape);
fixturesDef.add(smallCircleFixtureDef);
final trapeziumVertices = isLeft
final trapeziumVertices = side.isLeft
? [
Vector2(bigCircleShape.position.x, bigCircleShape.radius),
Vector2(smallCircleShape.position.x, smallCircleShape.radius),
@ -173,7 +141,8 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
@override
Future<void> onLoad() async {
await super.onLoad();
paint = Paint()..color = Colors.transparent;
renderBody = false;
await Future.wait([
_loadSprite(),
_anchorToJoint(),
@ -214,61 +183,66 @@ class Flipper extends BodyComponent with KeyboardHandler, InitialPosition {
///
/// The end of a [Flipper] depends on its [Flipper.side].
/// {@endtemplate}
class FlipperAnchor extends JointAnchor {
class _FlipperAnchor extends JointAnchor {
/// {@macro flipper_anchor}
FlipperAnchor({
_FlipperAnchor({
required Flipper flipper,
}) {
initialPosition = Vector2(
flipper.side.isLeft
? flipper.body.position.x - Flipper.size.x / 2
: flipper.body.position.x + Flipper.size.x / 2,
flipper.body.position.x + ((Flipper.size.x * flipper.side.direction) / 2),
flipper.body.position.y,
);
}
}
/// {@template flipper_anchor_revolute_joint_def}
/// Hinges one end of [Flipper] to a [FlipperAnchor] to achieve an arc motion.
/// Hinges one end of [Flipper] to a [_FlipperAnchor] to achieve an arc motion.
/// {@endtemplate}
class FlipperAnchorRevoluteJointDef extends RevoluteJointDef {
class _FlipperAnchorRevoluteJointDef extends RevoluteJointDef {
/// {@macro flipper_anchor_revolute_joint_def}
FlipperAnchorRevoluteJointDef({
_FlipperAnchorRevoluteJointDef({
required Flipper flipper,
required FlipperAnchor anchor,
}) {
required _FlipperAnchor anchor,
}) : side = flipper.side {
initialize(
flipper.body,
anchor.body,
anchor.body.position,
);
enableLimit = true;
final angle = (flipper.side.isLeft ? _sweepingAngle : -_sweepingAngle) / 2;
enableLimit = true;
final angle = (_sweepingAngle * -side.direction) / 2;
lowerAngle = upperAngle = angle;
}
/// The total angle of the arc motion.
static const _sweepingAngle = math.pi / 3.5;
final BoardSide side;
}
class _FlipperJoint extends RevoluteJoint {
_FlipperJoint(_FlipperAnchorRevoluteJointDef def)
: side = def.side,
super(def);
final BoardSide side;
// TODO(alestiago): Remove once Forge2D supports custom joints.
void create(World world) {
world.joints.add(this);
bodyA.joints.add(this);
bodyB.joints.add(this);
}
/// Unlocks the [Flipper] from its resting position.
///
/// The [Flipper] is locked when initialized in order to force it to be at
/// its resting position.
// TODO(alestiago): consider refactor once the issue is solved:
// https://github.com/flame-engine/forge2d/issues/36
static void unlock(RevoluteJoint joint, BoardSide side) {
late final double upperLimit, lowerLimit;
switch (side) {
case BoardSide.left:
lowerLimit = -joint.lowerLimit;
upperLimit = joint.upperLimit;
break;
case BoardSide.right:
lowerLimit = joint.lowerLimit;
upperLimit = -joint.upperLimit;
}
joint.setLimits(lowerLimit, upperLimit);
void unlock() {
setLimits(
lowerLimit * side.direction,
-upperLimit * side.direction,
);
}
}

@ -0,0 +1,131 @@
// 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/blueprint.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 nesting [Blueprint] is implemented.
class FlutterForest extends Component
with HasGameRef<PinballGame>, BlocComponent<GameBloc, GameState> {
/// {@macro flutter_forest}
FlutterForest({
required this.position,
});
/// The position of the [FlutterForest] on the [Board].
final Vector2 position;
@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.addFromBlueprint(BallBlueprint(position: position));
}
@override
Future<void> onLoad() async {
gameRef.addContactCallback(DashNestBumperBallContactCallback());
// TODO(alestiago): adjust positioning once sprites are added.
final smallLeftNest = SmallDashNestBumper(id: 'small_left_nest')
..initialPosition = position + Vector2(-4.8, 2.8);
final smallRightNest = SmallDashNestBumper(id: 'small_right_nest')
..initialPosition = position + Vector2(0.5, -5.5);
final bigNest = BigDashNestBumper(id: 'big_nest')
..initialPosition = position;
await addAll([
smallLeftNest,
smallRightNest,
bigNest,
]);
}
}
/// {@template dash_nest_bumper}
/// Bumper located in the [FlutterForest].
/// {@endtemplate}
@visibleForTesting
abstract class DashNestBumper extends BodyComponent<PinballGame>
with ScorePoints, InitialPosition {
/// {@macro dash_nest_bumper}
DashNestBumper({required this.id});
/// Unique identifier for this [DashNestBumper].
///
/// Used to identify [DashNestBumper]s in [GameState.activatedDashNests].
final String id;
}
/// Listens when a [Ball] bounces bounces against a [DashNestBumper].
@visibleForTesting
class DashNestBumperBallContactCallback
extends ContactCallback<DashNestBumper, Ball> {
@override
void begin(DashNestBumper dashNestBumper, Ball ball, Contact _) {
dashNestBumper.gameRef.read<GameBloc>().add(
DashNestActivated(dashNestBumper.id),
);
}
}
/// {@macro dash_nest_bumper}
@visibleForTesting
class BigDashNestBumper extends DashNestBumper {
/// {@macro dash_nest_bumper}
BigDashNestBumper({required String id}) : super(id: id);
@override
int get points => 20;
@override
Body createBody() {
final shape = CircleShape()..radius = 2.5;
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()
..position = initialPosition
..userData = this;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
/// {@macro dash_nest_bumper}
@visibleForTesting
class SmallDashNestBumper extends DashNestBumper {
/// {@macro dash_nest_bumper}
SmallDashNestBumper({required String id}) : super(id: id);
@override
int get points => 10;
@override
Body createBody() {
final shape = CircleShape()..radius = 1;
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()
..position = initialPosition
..userData = this;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}

@ -1,62 +1,102 @@
// ignore_for_file: public_member_api_docs, avoid_renaming_method_parameters
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template jetpack_ramp}
/// Represents the upper left blue ramp of the [Board].
/// {@endtemplate}
class JetpackRamp extends Component with HasGameRef<PinballGame> {
/// {@macro jetpack_ramp}
JetpackRamp({
required this.position,
});
/// The position of this [JetpackRamp].
final Vector2 position;
/// A [Blueprint] which creates the [JetpackRamp].
class Jetpack extends Forge2DBlueprint {
@override
Future<void> onLoad() async {
const layer = Layer.jetpack;
void build(_) {
final position = Vector2(
PinballGame.boardBounds.left + 40.5,
PinballGame.boardBounds.top - 31.5,
);
gameRef.addContactCallback(
addAllContactCallback([
RampOpeningBallContactCallback<_JetpackRampOpening>(),
);
]);
final curvePath = Pathway.arc(
// TODO(ruialonso): Remove color when not needed.
// TODO(ruialonso): Use a bezier curve once control points are defined.
color: const Color.fromARGB(255, 8, 218, 241),
center: position,
width: 5,
radius: 18,
angle: math.pi,
final rightOpening = _JetpackRampOpening(
rotation: math.pi,
)..layer = layer;
)
..initialPosition = position + Vector2(12.9, -20)
..layer = Layer.opening;
final leftOpening = _JetpackRampOpening(
outsideLayer: Layer.spaceship,
rotation: math.pi,
)
..initialPosition = position + Vector2(-2.5, -20.2)
..initialPosition = position + Vector2(-2.5, -20)
..layer = Layer.jetpack;
final rightOpening = _JetpackRampOpening(
rotation: math.pi,
)
..initialPosition = position + Vector2(12.9, -20.2)
..layer = Layer.opening;
final jetpackRamp = JetpackRamp()
..initialPosition = position + Vector2(5, -20.2)
..layer = Layer.jetpack;
await addAll([
curvePath,
leftOpening,
addAll([
rightOpening,
leftOpening,
jetpackRamp,
]);
}
}
/// {@template jetpack_ramp}
/// Represents the upper left blue ramp of the [Board].
/// {@endtemplate}
class JetpackRamp extends BodyComponent with InitialPosition, Layered {
JetpackRamp() : super(priority: 2) {
layer = Layer.jetpack;
paint = Paint()
..color = const Color.fromARGB(255, 8, 218, 241)
..style = PaintingStyle.stroke;
}
/// Radius of the external arc.
static const _externalRadius = 18.0;
/// Width between walls of the ramp.
static const width = 5.0;
List<FixtureDef> _createFixtureDefs() {
final fixturesDef = <FixtureDef>[];
final externalCurveShape = ArcShape(
center: initialPosition,
arcRadius: _externalRadius,
angle: math.pi,
rotation: math.pi,
);
final externalFixtureDef = FixtureDef(externalCurveShape);
fixturesDef.add(externalFixtureDef);
final internalCurveShape = externalCurveShape.copyWith(
arcRadius: _externalRadius - width,
);
final internalFixtureDef = FixtureDef(internalCurveShape);
fixturesDef.add(internalFixtureDef);
return fixturesDef;
}
@override
Body createBody() {
final bodyDef = BodyDef()
..userData = this
..position = initialPosition;
final body = world.createBody(bodyDef);
_createFixtureDefs().forEach(body.createFixture);
return body;
}
}
/// {@template jetpack_ramp_opening}
/// [RampOpening] with [Layer.jetpack] to filter [Ball] collisions
/// inside [JetpackRamp].
@ -75,9 +115,7 @@ class _JetpackRampOpening extends RampOpening {
final double _rotation;
// TODO(ruialonso): Avoid magic number 3, should be propotional to
// [JetpackRamp].
static final Vector2 _size = Vector2(3, .1);
static final Vector2 _size = Vector2(JetpackRamp.width / 3, .1);
@override
Shape get shape => PolygonShape()

@ -1,5 +1,5 @@
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template joint_anchor}
/// Non visual [BodyComponent] used to hold a [BodyType.dynamic] in [Joint]s

@ -5,6 +5,7 @@ import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:geometry/geometry.dart' as geometry show centroid;
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template kicker}
/// Triangular [BodyType.static] body that propels the [Ball] towards the

@ -1,69 +1,118 @@
// ignore_for_file: public_member_api_docs, avoid_renaming_method_parameters
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template launcher_ramp}
/// The yellow left ramp, where the [Ball] goes through when launched from the
/// [Plunger].
/// {@endtemplate}
class LauncherRamp extends Component with HasGameRef<PinballGame> {
/// {@macro launcher_ramp}
LauncherRamp({
required this.position,
});
/// The position of this [LauncherRamp].
final Vector2 position;
/// A [Blueprint] which creates the [LauncherRamp].
class Launcher extends Forge2DBlueprint {
@override
Future<void> onLoad() async {
const layer = Layer.launcher;
gameRef.addContactCallback(
RampOpeningBallContactCallback<_LauncherRampOpening>(),
void build(_) {
final position = Vector2(
PinballGame.boardBounds.right - 31.3,
PinballGame.boardBounds.bottom + 33,
);
final launcherRampRotation =
-math.atan(18.6 / PinballGame.boardBounds.height);
final straightPath = Pathway.straight(
color: const Color.fromARGB(255, 34, 255, 0),
start: position + Vector2(-1.2, 10),
end: position + Vector2(-1.2, 117),
width: 5,
rotation: launcherRampRotation,
)
..initialPosition = position
..layer = layer;
final curvedPath = Pathway.arc(
color: const Color.fromARGB(255, 251, 255, 0),
center: position + Vector2(-2.8, 87.2),
radius: 16.3,
angle: math.pi / 2,
width: 5,
rotation: 3 * math.pi / 2,
)..layer = layer;
addAllContactCallback([
RampOpeningBallContactCallback<_LauncherRampOpening>(),
]);
final leftOpening = _LauncherRampOpening(rotation: math.pi / 2)
..initialPosition = position + Vector2(-11.8, 66.3)
..initialPosition = position + Vector2(-11.8, 72.7)
..layer = Layer.opening;
final rightOpening = _LauncherRampOpening(rotation: 0)
..initialPosition = position + Vector2(-4.9, 59.4)
..initialPosition = position + Vector2(-5.4, 65.4)
..layer = Layer.opening;
await addAll([
straightPath,
curvedPath,
final launcherRamp = LauncherRamp()
..initialPosition = position + Vector2(1.7, 0)
..layer = Layer.launcher;
addAll([
leftOpening,
rightOpening,
launcherRamp,
]);
}
}
/// {@template launcher_ramp}
/// The yellow right ramp, where the [Ball] goes through when launched from the
/// [Plunger].
/// {@endtemplate}
class LauncherRamp extends BodyComponent with InitialPosition, Layered {
/// {@macro launcher_ramp}
LauncherRamp() : super(priority: 2) {
layer = Layer.launcher;
paint = Paint()
..color = const Color.fromARGB(255, 251, 255, 0)
..style = PaintingStyle.stroke;
}
/// Width between walls of the ramp.
static const width = 5.0;
/// Radius of the external arc at the top of the ramp.
static const _externalRadius = 16.3;
List<FixtureDef> _createFixtureDefs() {
final fixturesDef = <FixtureDef>[];
final startPosition = initialPosition + Vector2(0, 3);
final endPosition = initialPosition + Vector2(0, 130);
final rightStraightShape = EdgeShape()
..set(
startPosition..rotate(PinballGame.boardPerspectiveAngle),
endPosition..rotate(PinballGame.boardPerspectiveAngle),
);
final rightStraightFixtureDef = FixtureDef(rightStraightShape);
fixturesDef.add(rightStraightFixtureDef);
final leftStraightShape = EdgeShape()
..set(
startPosition - Vector2(width, 0),
endPosition - Vector2(width, 0),
);
final leftStraightFixtureDef = FixtureDef(leftStraightShape);
fixturesDef.add(leftStraightFixtureDef);
final externalCurveShape = ArcShape(
center: initialPosition + Vector2(-28.2, 132),
arcRadius: _externalRadius,
angle: math.pi / 2,
rotation: 3 * math.pi / 2,
);
final externalCurveFixtureDef = FixtureDef(externalCurveShape);
fixturesDef.add(externalCurveFixtureDef);
final internalCurveShape = externalCurveShape.copyWith(
arcRadius: _externalRadius - width,
);
final internalCurveFixtureDef = FixtureDef(internalCurveShape);
fixturesDef.add(internalCurveFixtureDef);
return fixturesDef;
}
@override
Body createBody() {
final bodyDef = BodyDef()
..userData = this
..position = initialPosition;
final body = world.createBody(bodyDef);
_createFixtureDefs().forEach(body.createFixture);
return body;
}
}
/// {@template launcher_ramp_opening}
/// [RampOpening] with [Layer.launcher] to filter [Ball]s collisions
/// inside [LauncherRamp].
@ -80,9 +129,7 @@ class _LauncherRampOpening extends RampOpening {
final double _rotation;
// TODO(ruialonso): Avoid magic number 3, should be propotional to
// [JetpackRamp].
static final Vector2 _size = Vector2(3, .1);
static final Vector2 _size = Vector2(LauncherRamp.width / 3, .1);
@override
Shape get shape => PolygonShape()

@ -1,168 +0,0 @@
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:geometry/geometry.dart';
import 'package:pinball/game/game.dart';
/// {@template pathway}
/// [Pathway] creates lines of various shapes.
///
/// [BodyComponent]s such as a Ball can collide and move along a [Pathway].
/// {@endtemplate}
class Pathway extends BodyComponent with InitialPosition, Layered {
Pathway._({
// TODO(ruialonso): remove color when assets added.
Color? color,
required List<List<Vector2>> paths,
}) : _paths = paths {
paint = Paint()
..color = color ?? const Color.fromARGB(0, 0, 0, 0)
..style = PaintingStyle.stroke;
}
/// Creates a uniform unidirectional (straight) [Pathway].
///
/// Does so with two [ChainShape] separated by a [width]. Can
/// be rotated by a given [rotation] in radians.
///
/// If [singleWall] is true, just one [ChainShape] is created.
factory Pathway.straight({
Color? color,
required Vector2 start,
required Vector2 end,
required double width,
double rotation = 0,
bool singleWall = false,
}) {
final paths = <List<Vector2>>[];
// TODO(ruialonso): Refactor repetitive logic
final firstWall = [
start.clone(),
end.clone(),
].map((vector) => vector..rotate(rotation)).toList();
paths.add(firstWall);
if (!singleWall) {
final secondWall = [
start + Vector2(width, 0),
end + Vector2(width, 0),
].map((vector) => vector..rotate(rotation)).toList();
paths.add(secondWall);
}
return Pathway._(
color: color,
paths: paths,
);
}
/// Creates an arc [Pathway].
///
/// The [angle], in radians, specifies the size of the arc. For example, two
/// pi returns a complete circumference.
///
/// Does so with two [ChainShape] separated by a [width]. Which can be
/// rotated by a given [rotation] in radians.
///
/// The outer radius is specified by [radius], whilst the inner one is
/// equivalent to the [radius] minus the [width].
///
/// If [singleWall] is true, just one [ChainShape] is created.
factory Pathway.arc({
Color? color,
required Vector2 center,
required double width,
required double radius,
required double angle,
double rotation = 0,
bool singleWall = false,
}) {
final paths = <List<Vector2>>[];
// TODO(ruialonso): Refactor repetitive logic
final outerWall = calculateArc(
center: center,
radius: radius,
angle: angle,
offsetAngle: rotation,
);
paths.add(outerWall);
if (!singleWall) {
final innerWall = calculateArc(
center: center,
radius: radius - width,
angle: angle,
offsetAngle: rotation,
);
paths.add(innerWall);
}
return Pathway._(
color: color,
paths: paths,
);
}
/// Creates a bezier curve [Pathway].
///
/// Does so with two [ChainShape] separated by a [width]. Which can be
/// rotated by a given [rotation] in radians.
///
/// First and last [controlPoints] set the beginning and end of the curve,
/// inner points between them set its final shape.
///
/// If [singleWall] is true, just one [ChainShape] is created.
factory Pathway.bezierCurve({
Color? color,
required List<Vector2> controlPoints,
required double width,
double rotation = 0,
bool singleWall = false,
}) {
final paths = <List<Vector2>>[];
// TODO(ruialonso): Refactor repetitive logic
final firstWall = calculateBezierCurve(controlPoints: controlPoints)
.map((vector) => vector..rotate(rotation))
.toList();
paths.add(firstWall);
if (!singleWall) {
final secondWall = calculateBezierCurve(
controlPoints: controlPoints
.map((vector) => vector + Vector2(width, -width))
.toList(),
).map((vector) => vector..rotate(rotation)).toList();
paths.add(secondWall);
}
return Pathway._(
color: color,
paths: paths,
);
}
final List<List<Vector2>> _paths;
List<FixtureDef> _createFixtureDefs() {
final fixturesDef = <FixtureDef>[];
for (final path in _paths) {
final chain = ChainShape()..createChain(path);
fixturesDef.add(FixtureDef(chain));
}
return fixturesDef;
}
@override
Body createBody() {
final bodyDef = BodyDef()..position = initialPosition;
final body = world.createBody(bodyDef);
_createFixtureDefs().forEach(body.createFixture);
return body;
}
}

@ -2,6 +2,7 @@ import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/services.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template plunger}
/// [Plunger] serves as a spring, that shoots the ball on the right side of the
@ -20,9 +21,15 @@ class Plunger extends BodyComponent with KeyboardHandler, InitialPosition {
@override
Body createBody() {
final shape = PolygonShape()..setAsBoxXY(2, 0.75);
final shape = PolygonShape()
..setAsBox(
1.35,
0.5,
Vector2.zero(),
PinballGame.boardPerspectiveAngle,
);
final fixtureDef = FixtureDef(shape)..density = 5;
final fixtureDef = FixtureDef(shape)..density = 20;
final bodyDef = BodyDef()
..position = initialPosition
@ -35,7 +42,7 @@ class Plunger extends BodyComponent with KeyboardHandler, InitialPosition {
/// Set a constant downward velocity on the [Plunger].
void _pull() {
body.linearVelocity = Vector2(0, -3);
body.linearVelocity = Vector2(0, -7);
}
/// Set an upward velocity on the [Plunger].
@ -43,7 +50,7 @@ class Plunger extends BodyComponent with KeyboardHandler, InitialPosition {
/// 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) * 9;
final velocity = (initialPosition.y - body.position.y) * 4;
body.linearVelocity = Vector2(0, velocity);
}
@ -120,12 +127,12 @@ class PlungerAnchorPrismaticJointDef extends PrismaticJointDef {
plunger.body,
anchor.body,
anchor.body.position,
Vector2(0, -1),
Vector2(18.6, PinballGame.boardBounds.height),
);
enableLimit = true;
lowerTranslation = double.negativeInfinity;
enableMotor = true;
motorSpeed = 50;
motorSpeed = 80;
maxMotorForce = motorSpeed;
collideConnected = true;
}

@ -2,6 +2,7 @@
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template ramp_orientation}
/// Determines if a ramp is facing [up] or [down] on the [Board].
@ -36,10 +37,10 @@ abstract class RampOpening extends BodyComponent with InitialPosition, Layered {
final Layer _pathwayLayer;
final Layer _outsideLayer;
/// Mask of category bits for collision inside [Pathway].
/// Mask of category bits for collision inside pathway.
Layer get pathwayLayer => _pathwayLayer;
/// Mask of category bits for collision outside [Pathway].
/// Mask of category bits for collision outside pathway.
Layer get outsideLayer => _outsideLayer;
/// The [Shape] of the [RampOpening].
@ -64,7 +65,7 @@ abstract class RampOpening extends BodyComponent with InitialPosition, Layered {
}
/// {@template ramp_opening_ball_contact_callback}
/// Detects when a [Ball] enters or exits a [Pathway] ramp through a
/// Detects when a [Ball] enters or exits a pathway ramp through a
/// [RampOpening].
///
/// Modifies [Ball]'s [Layer] accordingly depending on whether the [Ball] is

@ -1,34 +0,0 @@
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
/// {@template round_bumper}
/// Circular body that repels a [Ball] on contact, increasing the score.
/// {@endtemplate}
class RoundBumper extends BodyComponent with ScorePoints, InitialPosition {
/// {@macro round_bumper}
RoundBumper({
required double radius,
required int points,
}) : _radius = radius,
_points = points;
/// The radius of the [RoundBumper].
final double _radius;
/// Points awarded from hitting this [RoundBumper].
final int _points;
@override
int get points => _points;
@override
Body createBody() {
final shape = CircleShape()..radius = _radius;
final fixtureDef = FixtureDef(shape)..restitution = 1;
final bodyDef = BodyDef()..position = initialPosition;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}

@ -2,11 +2,12 @@
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 on BodyComponent {
mixin ScorePoints<T extends Forge2DGame> on BodyComponent<T> {
/// {@macro score_points}
int get points;
@ -26,6 +27,8 @@ class BallScorePointsCallback extends ContactCallback<Ball, ScorePoints> {
ScorePoints scorePoints,
Contact _,
) {
ball.gameRef.read<GameBloc>().add(Scored(points: scorePoints.points));
ball.controller.gameRef.read<GameBloc>().add(
Scored(points: scorePoints.points),
);
}
}

@ -8,6 +8,7 @@ import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets;
/// A [Blueprint] which creates the spaceship feature.
class Spaceship extends Forge2DBlueprint {
@ -15,7 +16,7 @@ class Spaceship extends Forge2DBlueprint {
static const radius = 10.0;
@override
void build() {
void build(_) {
final position = Vector2(
PinballGame.boardBounds.left + radius + 15,
PinballGame.boardBounds.center.dy + 30,

@ -4,6 +4,7 @@ import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/components/components.dart';
import 'package:pinball/game/pinball_game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template wall}
/// A continuous generic and [BodyType.static] barrier that divides a game area.
@ -77,6 +78,6 @@ class BottomWall extends Wall {
class BottomWallBallContactCallback extends ContactCallback<Ball, BottomWall> {
@override
void begin(Ball ball, BottomWall wall, Contact contact) {
ball.lost();
ball.controller.lost();
}
}

@ -1,12 +1,13 @@
import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_components/pinball_components.dart' as components;
/// Add methods to help loading and caching game assets.
extension PinballGameAssetsX on PinballGame {
/// Pre load the initial assets of the game.
Future<void> preLoadAssets() async {
await Future.wait([
images.load(Assets.images.components.ball.path),
images.load(components.Assets.images.ball.keyName),
images.load(Assets.images.components.flipper.path),
images.load(Assets.images.components.spaceship.androidTop.path),
images.load(Assets.images.components.spaceship.androidBottom.path),

@ -1,6 +1,7 @@
// ignore_for_file: public_member_api_docs
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/extensions.dart';
import 'package:flame/input.dart';
import 'package:flame_bloc/flame_bloc.dart';
@ -25,6 +26,8 @@ class PinballGame extends Forge2DGame
width: boardSize.x,
height: -boardSize.y,
);
static final boardPerspectiveAngle =
-math.atan(18.6 / PinballGame.boardBounds.height);
@override
void onAttach() {
@ -61,13 +64,8 @@ class PinballGame extends Forge2DGame
}
Future<void> _addPlunger() async {
plunger = Plunger(compressionDistance: 2);
plunger.initialPosition = boardBounds.bottomRight.toVector2() +
Vector2(
-5,
10,
);
plunger = Plunger(compressionDistance: 29)
..initialPosition = boardBounds.center.toVector2() + Vector2(41.5, -49);
await add(plunger);
}
@ -75,39 +73,20 @@ class PinballGame extends Forge2DGame
await add(
BonusWord(
position: Vector2(
boardBounds.center.dx,
boardBounds.bottom + 10,
boardBounds.center.dx - 3.07,
boardBounds.center.dy - 2.4,
),
),
);
}
Future<void> _addPaths() async {
final jetpackRamp = JetpackRamp(
position: Vector2(
PinballGame.boardBounds.left + 40.5,
PinballGame.boardBounds.top - 31.5,
),
);
final launcherRamp = LauncherRamp(
position: Vector2(
PinballGame.boardBounds.right - 30,
PinballGame.boardBounds.bottom + 40,
),
);
await addAll([
jetpackRamp,
launcherRamp,
]);
unawaited(addFromBlueprint(Jetpack()));
unawaited(addFromBlueprint(Launcher()));
}
void spawnBall() {
final ball = Ball();
add(
ball
..initialPosition = plunger.body.position + Vector2(0, ball.size.y / 2),
);
addFromBlueprint(BallBlueprint(position: plunger.body.position));
}
}
@ -116,8 +95,6 @@ class DebugPinballGame extends PinballGame with TapDetector {
@override
void onTapUp(TapUpInfo info) {
add(
Ball()..initialPosition = info.eventPosition.game,
);
addFromBlueprint(BallBlueprint(position: info.eventPosition.game));
}
}

@ -70,7 +70,7 @@ class _PinballGameViewState extends State<PinballGameView> {
showDialog<void>(
context: context,
builder: (_) {
return const GameOverDialog();
return GameOverDialog(theme: widget.theme.characterTheme);
},
);
}

@ -1,21 +1,40 @@
import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball_theme/pinball_theme.dart';
/// {@template game_over_dialog}
/// [Dialog] displayed when the [PinballGame] is over.
/// {@endtemplate}
class GameOverDialog extends StatelessWidget {
/// {@macro game_over_dialog}
const GameOverDialog({Key? key}) : super(key: key);
const GameOverDialog({Key? key, required this.theme}) : super(key: key);
/// Current [CharacterTheme] to customize dialog
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return const Dialog(
final l10n = context.l10n;
return Dialog(
child: SizedBox(
width: 200,
height: 200,
child: Center(
child: Text('Game Over'),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(l10n.gameOver),
TextButton(
onPressed: () => Navigator.of(context).push<void>(
LeaderboardPage.route(theme: theme),
),
child: Text(l10n.leaderboard),
),
],
),
),
),
);

@ -15,12 +15,8 @@ class $AssetsImagesGen {
class $AssetsImagesComponentsGen {
const $AssetsImagesComponentsGen();
AssetGenImage get ball =>
const AssetGenImage('assets/images/components/ball.png');
AssetGenImage get flipper =>
const AssetGenImage('assets/images/components/flipper.png');
AssetGenImage get sauce =>
const AssetGenImage('assets/images/components/sauce.png');
$AssetsImagesComponentsSpaceshipGen get spaceship =>
const $AssetsImagesComponentsSpaceshipGen();
}

@ -23,5 +23,33 @@
"characterSelectionTitle": "Choose your character!",
"@characterSelectionTitle": {
"description": "Title text displayed on the character selection page"
},
"gameOver": "Game Over",
"@gameOver": {
"description": "Text displayed on the ending dialog when game finishes"
},
"leaderboard": "Leaderboard",
"@leaderboard": {
"description": "Text displayed on the ending dialog leaderboard button"
},
"rank": "Rank",
"@rank": {
"description": "Text displayed on the leaderboard page header rank column"
},
"character": "Character",
"@character": {
"description": "Text displayed on the leaderboard page header character column"
},
"username": "Username",
"@username": {
"description": "Text displayed on the leaderboard page header userName column"
},
"score": "Score",
"@score": {
"description": "Text displayed on the leaderboard page header score column"
},
"retry": "Retry",
"@retry": {
"description": "Text displayed on the retry button leaders board page"
}
}

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
part 'leaderboard_event.dart';
part 'leaderboard_state.dart';
@ -30,10 +31,16 @@ class LeaderboardBloc extends Bloc<LeaderboardEvent, LeaderboardState> {
try {
final top10Leaderboard =
await _leaderboardRepository.fetchTop10Leaderboard();
final leaderboardEntries = <LeaderboardEntry>[];
top10Leaderboard.asMap().forEach(
(index, value) => leaderboardEntries.add(value.toEntry(index + 1)),
);
emit(
state.copyWith(
status: LeaderboardStatus.success,
leaderboard: top10Leaderboard,
leaderboard: leaderboardEntries,
),
);
} catch (error) {

@ -9,7 +9,7 @@ abstract class LeaderboardEvent extends Equatable {
}
/// {@template top_10_fetched}
/// Request the top 10 [LeaderboardEntry]s.
/// Request the top 10 [LeaderboardEntryData]s.
/// {endtemplate}
class Top10Fetched extends LeaderboardEvent {
/// {@macro top_10_fetched}
@ -20,7 +20,7 @@ class Top10Fetched extends LeaderboardEvent {
}
/// {@template leaderboard_entry_added}
/// Writes a new [LeaderboardEntry].
/// Writes a new [LeaderboardEntryData].
///
/// Should be added when a player finishes a game.
/// {endtemplate}
@ -28,8 +28,8 @@ class LeaderboardEntryAdded extends LeaderboardEvent {
/// {@macro leaderboard_entry_added}
const LeaderboardEntryAdded({required this.entry});
/// [LeaderboardEntry] to be written to the remote storage.
final LeaderboardEntry entry;
/// [LeaderboardEntryData] to be written to the remote storage.
final LeaderboardEntryData entry;
@override
List<Object?> get props => [entry];

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

@ -0,0 +1,80 @@
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball_theme/pinball_theme.dart';
/// {@template leaderboard_entry}
/// A model representing a leaderboard entry containing the ranking position,
/// player's initials, score, and chosen character.
///
/// {@endtemplate}
class LeaderboardEntry {
/// {@macro leaderboard_entry}
LeaderboardEntry({
required this.rank,
required this.playerInitials,
required this.score,
required this.character,
});
/// Ranking position for [LeaderboardEntry].
final String rank;
/// Player's chosen initials for [LeaderboardEntry].
final String playerInitials;
/// Score for [LeaderboardEntry].
final int score;
/// [CharacterTheme] for [LeaderboardEntry].
final AssetGenImage character;
}
/// Converts [LeaderboardEntryData] from repository to [LeaderboardEntry].
extension LeaderboardEntryDataX on LeaderboardEntryData {
/// Conversion method to [LeaderboardEntry]
LeaderboardEntry toEntry(int position) {
return LeaderboardEntry(
rank: position.toString(),
playerInitials: playerInitials,
score: score,
character: character.toTheme.characterAsset,
);
}
}
/// Converts [CharacterType] to [CharacterTheme] to show on UI character theme
/// from repository.
extension CharacterTypeX on CharacterType {
/// Conversion method to [CharacterTheme]
CharacterTheme get toTheme {
switch (this) {
case CharacterType.dash:
return const DashTheme();
case CharacterType.sparky:
return const SparkyTheme();
case CharacterType.android:
return const AndroidTheme();
case CharacterType.dino:
return const DinoTheme();
}
}
}
/// Converts [CharacterTheme] to [CharacterType] to persist at repository the
/// character theme from UI.
extension CharacterThemeX on CharacterTheme {
/// Conversion method to [CharacterType]
CharacterType get toType {
switch (runtimeType) {
case DashTheme:
return CharacterType.dash;
case SparkyTheme:
return CharacterType.sparky;
case AndroidTheme:
return CharacterType.android;
case DinoTheme:
return CharacterType.dino;
default:
return CharacterType.dash;
}
}
}

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

@ -23,10 +23,45 @@ List<Vector2> calculateArc({
final points = <Vector2>[];
for (var i = 0; i < precision; i++) {
final xCoord = center.x + radius * math.cos((stepAngle * i) + offsetAngle);
final yCoord = center.y - radius * math.sin((stepAngle * i) + offsetAngle);
final x = center.x + radius * math.cos((stepAngle * i) + offsetAngle);
final y = center.y - radius * math.sin((stepAngle * i) + offsetAngle);
final point = Vector2(xCoord, yCoord);
final point = Vector2(x, y);
points.add(point);
}
return points;
}
/// Calculates all [Vector2]s of an ellipse.
///
/// An ellipse can be achieved by specifying a [center], a [majorRadius] and a
/// [minorRadius].
///
/// The higher the [precision], the more [Vector2]s will be calculated;
/// achieving a more rounded ellipse.
///
/// For more information read: https://en.wikipedia.org/wiki/Ellipse.
List<Vector2> calculateEllipse({
required Vector2 center,
required double majorRadius,
required double minorRadius,
int precision = 100,
}) {
assert(
0 < minorRadius && minorRadius <= majorRadius,
'smallRadius ($minorRadius) and bigRadius ($majorRadius) must be in '
'range 0 < smallRadius <= bigRadius',
);
final stepAngle = 2 * math.pi / (precision - 1);
final points = <Vector2>[];
for (var i = 0; i < precision; i++) {
final x = center.x + minorRadius * math.cos(stepAngle * i);
final y = center.y - majorRadius * math.sin(stepAngle * i);
final point = Vector2(x, y);
points.add(point);
}
@ -63,17 +98,15 @@ List<Vector2> calculateBezierCurve({
final points = <Vector2>[];
do {
var xCoord = 0.0;
var yCoord = 0.0;
var x = 0.0;
var y = 0.0;
for (var i = 0; i <= n; i++) {
final point = controlPoints[i];
xCoord +=
binomial(n, i) * math.pow(1 - t, n - i) * math.pow(t, i) * point.x;
yCoord +=
binomial(n, i) * math.pow(1 - t, n - i) * math.pow(t, i) * point.y;
x += binomial(n, i) * math.pow(1 - t, n - i) * math.pow(t, i) * point.x;
y += binomial(n, i) * math.pow(1 - t, n - i) * math.pow(t, i) * point.y;
}
points.add(Vector2(xCoord, yCoord));
points.add(Vector2(x, y));
t = t + step;
} while (t <= 1);

@ -33,6 +33,46 @@ void main() {
});
});
group('calculateEllipse', () {
test('returns by default 100 points as indicated by precision', () {
final points = calculateEllipse(
center: Vector2.zero(),
majorRadius: 100,
minorRadius: 50,
);
expect(points.length, 100);
});
test('returns as many points as indicated by precision', () {
final points = calculateEllipse(
center: Vector2.zero(),
majorRadius: 100,
minorRadius: 50,
precision: 50,
);
expect(points.length, 50);
});
test('fails if radius not in range', () {
expect(
() => calculateEllipse(
center: Vector2.zero(),
majorRadius: 100,
minorRadius: 150,
),
throwsA(isA<AssertionError>()),
);
expect(
() => calculateEllipse(
center: Vector2.zero(),
majorRadius: 100,
minorRadius: 0,
),
throwsA(isA<AssertionError>()),
);
});
});
group('calculateBezierCurve', () {
test('fails if step not in range', () {
expect(

@ -83,15 +83,15 @@ class LeaderboardRepository {
final FirebaseFirestore _firebaseFirestore;
/// Acquires top 10 [LeaderboardEntry]s.
Future<List<LeaderboardEntry>> fetchTop10Leaderboard() async {
final leaderboardEntries = <LeaderboardEntry>[];
/// Acquires top 10 [LeaderboardEntryData]s.
Future<List<LeaderboardEntryData>> fetchTop10Leaderboard() async {
final leaderboardEntries = <LeaderboardEntryData>[];
late List<QueryDocumentSnapshot> documents;
try {
final querySnapshot = await _firebaseFirestore
.collection('leaderboard')
.orderBy('score')
.orderBy('score', descending: true)
.limit(10)
.get();
documents = querySnapshot.docs;
@ -103,7 +103,7 @@ class LeaderboardRepository {
final data = document.data() as Map<String, dynamic>?;
if (data != null) {
try {
leaderboardEntries.add(LeaderboardEntry.fromJson(data));
leaderboardEntries.add(LeaderboardEntryData.fromJson(data));
} catch (error, stackTrace) {
throw LeaderboardDeserializationException(error, stackTrace);
}
@ -115,7 +115,9 @@ class LeaderboardRepository {
/// Adds player's score entry to the leaderboard and gets their
/// [LeaderboardRanking].
Future<LeaderboardRanking> addLeaderboardEntry(LeaderboardEntry entry) async {
Future<LeaderboardRanking> addLeaderboardEntry(
LeaderboardEntryData entry,
) async {
late DocumentReference entryReference;
try {
entryReference = await _firebaseFirestore
@ -128,7 +130,7 @@ class LeaderboardRepository {
try {
final querySnapshot = await _firebaseFirestore
.collection('leaderboard')
.orderBy('score')
.orderBy('score', descending: true)
.get();
// TODO(allisonryan0002): see if we can find a more performant solution.

@ -1,9 +1,9 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'leaderboard_entry.g.dart';
part 'leaderboard_entry_data.g.dart';
/// Google character type associated with a [LeaderboardEntry].
/// Google character type associated with a [LeaderboardEntryData].
enum CharacterType {
/// Dash character.
dash,
@ -18,7 +18,7 @@ enum CharacterType {
dino,
}
/// {@template leaderboard_entry}
/// {@template leaderboard_entry_data}
/// A model representing a leaderboard entry containing the player's initials,
/// score, and chosen character.
///
@ -34,42 +34,42 @@ enum CharacterType {
/// ```
/// {@endtemplate}
@JsonSerializable()
class LeaderboardEntry extends Equatable {
/// {@macro leaderboard_entry}
const LeaderboardEntry({
class LeaderboardEntryData extends Equatable {
/// {@macro leaderboard_entry_data}
const LeaderboardEntryData({
required this.playerInitials,
required this.score,
required this.character,
});
/// Factory which converts a [Map] into a [LeaderboardEntry].
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) {
/// Factory which converts a [Map] into a [LeaderboardEntryData].
factory LeaderboardEntryData.fromJson(Map<String, dynamic> json) {
return _$LeaderboardEntryFromJson(json);
}
/// Converts the [LeaderboardEntry] to [Map].
/// Converts the [LeaderboardEntryData] to [Map].
Map<String, dynamic> toJson() => _$LeaderboardEntryToJson(this);
/// Player's chosen initials for [LeaderboardEntry].
/// Player's chosen initials for [LeaderboardEntryData].
///
/// Example: 'ABC'.
@JsonKey(name: 'playerInitials')
final String playerInitials;
/// Score for [LeaderboardEntry].
/// Score for [LeaderboardEntryData].
///
/// Example: 1500.
@JsonKey(name: 'score')
final int score;
/// [CharacterType] for [LeaderboardEntry].
/// [CharacterType] for [LeaderboardEntryData].
///
/// Example: [CharacterType.dash].
@JsonKey(name: 'character')
final CharacterType character;
/// An empty [LeaderboardEntry] object.
static const empty = LeaderboardEntry(
/// An empty [LeaderboardEntryData] object.
static const empty = LeaderboardEntryData(
playerInitials: '',
score: 0,
character: CharacterType.dash,

@ -1,19 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'leaderboard_entry.dart';
part of 'leaderboard_entry_data.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LeaderboardEntry _$LeaderboardEntryFromJson(Map<String, dynamic> json) =>
LeaderboardEntry(
LeaderboardEntryData _$LeaderboardEntryFromJson(Map<String, dynamic> json) =>
LeaderboardEntryData(
playerInitials: json['playerInitials'] as String,
score: json['score'] as int,
character: $enumDecode(_$CharacterTypeEnumMap, json['character']),
);
Map<String, dynamic> _$LeaderboardEntryToJson(LeaderboardEntry instance) =>
Map<String, dynamic> _$LeaderboardEntryToJson(LeaderboardEntryData instance) =>
<String, dynamic>{
'playerInitials': instance.playerInitials,
'score': instance.score,

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

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

@ -57,7 +57,7 @@ void main() {
final top10Leaderboard = top10Scores
.map(
(score) => LeaderboardEntry(
(score) => LeaderboardEntryData(
playerInitials: 'user$score',
score: score,
character: CharacterType.dash,
@ -82,7 +82,7 @@ void main() {
when(() => firestore.collection('leaderboard'))
.thenAnswer((_) => collectionReference);
when(() => collectionReference.orderBy('score'))
when(() => collectionReference.orderBy('score', descending: true))
.thenAnswer((_) => query);
when(() => query.limit(10)).thenAnswer((_) => query);
when(query.get).thenAnswer((_) async => querySnapshot);
@ -144,7 +144,7 @@ void main() {
entryScore,
1000,
];
final leaderboardEntry = LeaderboardEntry(
final leaderboardEntry = LeaderboardEntryData(
playerInitials: 'ABC',
score: entryScore,
character: CharacterType.dash,
@ -173,7 +173,7 @@ void main() {
.thenAnswer((_) => collectionReference);
when(() => collectionReference.add(any()))
.thenAnswer((_) async => documentReference);
when(() => collectionReference.orderBy('score'))
when(() => collectionReference.orderBy('score', descending: true))
.thenAnswer((_) => query);
when(query.get).thenAnswer((_) async => querySnapshot);
when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots);
@ -203,7 +203,8 @@ void main() {
test(
'throws FetchPlayerRankingException when Exception occurs '
'when trying to retrieve information from firestore', () async {
when(() => collectionReference.orderBy('score')).thenThrow(Exception());
when(() => collectionReference.orderBy('score', descending: true))
.thenThrow(Exception());
expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),

@ -9,21 +9,21 @@ void main() {
'character': 'dash',
};
const leaderboardEntry = LeaderboardEntry(
const leaderboardEntry = LeaderboardEntryData(
playerInitials: 'ABC',
score: 1500,
character: CharacterType.dash,
);
test('can be instantiated', () {
const leaderboardEntry = LeaderboardEntry.empty;
const leaderboardEntry = LeaderboardEntryData.empty;
expect(leaderboardEntry, isNotNull);
});
test('supports value equality.', () {
const leaderboardEntry = LeaderboardEntry.empty;
const leaderboardEntry2 = LeaderboardEntry.empty;
const leaderboardEntry = LeaderboardEntryData.empty;
const leaderboardEntry2 = LeaderboardEntryData.empty;
expect(leaderboardEntry, equals(leaderboardEntry2));
});
@ -33,7 +33,7 @@ void main() {
});
test('can be obtained from json', () {
final leaderboardEntryFrom = LeaderboardEntry.fromJson(data);
final leaderboardEntryFrom = LeaderboardEntryData.fromJson(data);
expect(leaderboardEntry, equals(leaderboardEntryFrom));
});

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

@ -0,0 +1,11 @@
# pinball_components
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
Package with the UI game components for the Pinball Game
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis

@ -0,0 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
analyzer:
exclude:
- lib/**/*.gen.dart

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

@ -0,0 +1,68 @@
/// GENERATED CODE - DO NOT MODIFY BY HAND
/// *****************************************************
/// FlutterGen
/// *****************************************************
import 'package:flutter/widgets.dart';
class $AssetsImagesGen {
const $AssetsImagesGen();
AssetGenImage get ball => const AssetGenImage('assets/images/ball.png');
}
class Assets {
Assets._();
static const $AssetsImagesGen images = $AssetsImagesGen();
}
class AssetGenImage extends AssetImage {
const AssetGenImage(String assetName)
: super(assetName, package: 'pinball_components');
Image image({
Key? key,
ImageFrameBuilder? frameBuilder,
ImageLoadingBuilder? loadingBuilder,
ImageErrorWidgetBuilder? errorBuilder,
String? semanticLabel,
bool excludeFromSemantics = false,
double? width,
double? height,
Color? color,
BlendMode? colorBlendMode,
BoxFit? fit,
AlignmentGeometry alignment = Alignment.center,
ImageRepeat repeat = ImageRepeat.noRepeat,
Rect? centerSlice,
bool matchTextDirection = false,
bool gaplessPlayback = false,
bool isAntiAlias = false,
FilterQuality filterQuality = FilterQuality.low,
}) {
return Image(
key: key,
image: this,
frameBuilder: frameBuilder,
loadingBuilder: loadingBuilder,
errorBuilder: errorBuilder,
semanticLabel: semanticLabel,
excludeFromSemantics: excludeFromSemantics,
width: width,
height: height,
color: color,
colorBlendMode: colorBlendMode,
fit: fit,
alignment: alignment,
repeat: repeat,
centerSlice: centerSlice,
matchTextDirection: matchTextDirection,
gaplessPlayback: gaplessPlayback,
isAntiAlias: isAntiAlias,
filterQuality: filterQuality,
);
}
String get path => assetName;
}

@ -0,0 +1,4 @@
library pinball_components;
export 'gen/assets.gen.dart';
export 'src/pinball_components.dart';

@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template ball}
/// A solid, [BodyType.dynamic] sphere that rolls and bounces around
/// {@endtemplate}
class Ball<T extends Forge2DGame> extends BodyComponent<T>
with Layered, InitialPosition {
/// {@macro ball_body}
Ball({
required this.baseColor,
}) {
// TODO(ruimiguel): while developing Ball can be launched by clicking mouse,
// and default layer is Layer.all. But on final game Ball will be always be
// be launched from Plunger and LauncherRamp will modify it to Layer.board.
// We need to see what happens if Ball appears from other place like nest
// bumper, it will need to explicit change layer to Layer.board then.
layer = Layer.board;
}
/// The size of the [Ball]
final Vector2 size = Vector2.all(3);
/// The base [Color] used to tint this [Ball]
final Color baseColor;
double _boostTimer = 0;
static const _boostDuration = 2.0;
@override
Future<void> onLoad() async {
await super.onLoad();
final sprite = await gameRef.loadSprite(Assets.images.ball.keyName);
final tint = baseColor.withOpacity(0.5);
await add(
SpriteComponent(
sprite: sprite,
size: size,
anchor: Anchor.center,
)..tint(tint),
);
}
@override
Body createBody() {
final shape = CircleShape()..radius = size.x / 2;
final fixtureDef = FixtureDef(shape)..density = 1;
final bodyDef = BodyDef()
..position = initialPosition
..userData = this
..type = BodyType.dynamic;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
/// Immediatly and completly [stop]s the ball.
///
/// The [Ball] will no longer be affected by any forces, including it's
/// weight and those emitted from collisions.
void stop() {
body.setType(BodyType.static);
}
/// Allows the [Ball] to be affected by forces.
///
/// If previously [stop]ed, the previous ball's velocity is not kept.
void resume() {
body.setType(BodyType.dynamic);
}
@override
void update(double dt) {
super.update(dt);
if (_boostTimer > 0) {
_boostTimer -= dt;
final direction = body.linearVelocity.normalized();
final effect = FireEffect(
burstPower: _boostTimer,
direction: direction,
position: body.position,
);
unawaited(gameRef.add(effect));
}
}
/// Applies a boost on this [Ball]
void boost(Vector2 impulse) {
body.applyLinearImpulse(impulse);
_boostTimer = _boostDuration;
}
}

@ -0,0 +1,5 @@
export 'ball.dart';
export 'fire_effect.dart';
export 'initial_position.dart';
export 'layer.dart';
export 'shapes/shapes.dart';

@ -0,0 +1,113 @@
import 'dart:math' as math;
import 'package:flame/extensions.dart';
import 'package:flame/particles.dart';
import 'package:flame_forge2d/flame_forge2d.dart' hide Particle;
import 'package:flutter/material.dart';
const _particleRadius = 0.25;
// TODO(erickzanardo): This component could just be a ParticleComponet,
/// unfortunately there is a Particle Component is not a PositionComponent,
/// which makes it hard to be used since we have camera transformations and on
// top of that, PositionComponent has a bug inside forge 2d games
///
/// https://github.com/flame-engine/flame/issues/1484
/// https://github.com/flame-engine/flame/issues/1484
/// {@template fire_effect}
/// A [BodyComponent] which creates a fire trail effect using the given
/// parameters
/// {@endtemplate}
class FireEffect extends BodyComponent {
/// {@macro fire_effect}
FireEffect({
required this.burstPower,
required this.position,
required this.direction,
});
/// A [double] value that will define how "strong" the burst of particles
/// will be
final double burstPower;
/// The position of the burst
final Vector2 position;
/// Which direction the burst will aim
final Vector2 direction;
late Particle _particle;
@override
Body createBody() {
final bodyDef = BodyDef()..position = position;
final fixtureDef = FixtureDef(CircleShape()..radius = 0)..isSensor = true;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
@override
Future<void> onLoad() async {
await super.onLoad();
final children = [
...List.generate(4, (index) {
return CircleParticle(
radius: _particleRadius,
paint: Paint()..color = Colors.yellow.darken((index + 1) / 4),
);
}),
...List.generate(4, (index) {
return CircleParticle(
radius: _particleRadius,
paint: Paint()..color = Colors.red.darken((index + 1) / 4),
);
}),
...List.generate(4, (index) {
return CircleParticle(
radius: _particleRadius,
paint: Paint()..color = Colors.orange.darken((index + 1) / 4),
);
}),
];
final rng = math.Random();
final spreadTween = Tween<double>(begin: -0.2, end: 0.2);
_particle = Particle.generate(
count: (rng.nextDouble() * (burstPower * 10)).toInt(),
generator: (_) {
final spread = Vector2(
spreadTween.transform(rng.nextDouble()),
spreadTween.transform(rng.nextDouble()),
);
final finalDirection = Vector2(direction.x, -direction.y) + spread;
final speed = finalDirection * (burstPower * 20);
return AcceleratedParticle(
lifespan: 5 / burstPower,
position: Vector2.zero(),
speed: speed,
child: children[rng.nextInt(children.length)],
);
},
);
}
@override
void update(double dt) {
super.update(dt);
_particle.update(dt);
if (_particle.shouldRemove) {
removeFromParent();
}
}
@override
void render(Canvas canvas) {
super.render(canvas);
_particle.render(canvas);
}
}

@ -8,6 +8,9 @@ import 'package:flutter/material.dart';
/// [BodyComponent]s with compatible [Layer]s can collide with each other,
/// ignoring others. This compatibility depends on bit masking operation
/// between layers. For more information read: https://en.wikipedia.org/wiki/Mask_(computing).
///
/// A parent [Layered] have priority against its children's layer. Them won't be
/// changed but will be ignored.
/// {@endtemplate}
mixin Layered<T extends Forge2DGame> on BodyComponent<T> {
Layer _layer = Layer.all;

@ -0,0 +1,54 @@
// ignore_for_file: public_member_api_docs
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:geometry/geometry.dart';
/// {@template arc_shape}
/// Creates an arc.
/// {@endtemplate}
class ArcShape extends ChainShape {
/// {@macro arc_shape}
ArcShape({
required this.center,
required this.arcRadius,
required this.angle,
this.rotation = 0,
}) {
createChain(
calculateArc(
center: center,
radius: arcRadius,
angle: angle,
offsetAngle: rotation,
),
);
}
/// The center of the arc.
final Vector2 center;
/// The radius of the arc.
// TODO(alestiago): Check if modifying the parent radius makes sense.
final double arcRadius;
/// Specifies the size of the arc, in radians.
///
/// For example, two pi returns a complete circumference.
final double angle;
/// Angle in radians to rotate the arc around its [center].
final double rotation;
ArcShape copyWith({
Vector2? center,
double? arcRadius,
double? angle,
double? rotation,
}) =>
ArcShape(
center: center ?? this.center,
arcRadius: arcRadius ?? this.arcRadius,
angle: angle ?? this.angle,
rotation: rotation ?? this.rotation,
);
}

@ -0,0 +1,28 @@
// ignore_for_file: public_member_api_docs
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:geometry/geometry.dart';
/// {@template bezier_curve_shape}
/// Creates a bezier curve.
/// {@endtemplate}
class BezierCurveShape extends ChainShape {
/// {@macro bezier_curve_shape}
BezierCurveShape({
required this.controlPoints,
}) {
createChain(calculateBezierCurve(controlPoints: controlPoints));
}
/// Specifies the control points of the curve.
///
/// First and last [controlPoints] set the beginning and end of the curve,
/// inner points between them set its final shape.
final List<Vector2> controlPoints;
/// Rotates the bezier curve by a given [angle] in radians.
void rotate(double angle) {
vertices.map((vector) => vector..rotate(angle)).toList();
}
}

@ -0,0 +1,53 @@
// ignore_for_file: public_member_api_docs
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:geometry/geometry.dart';
/// {@template ellipse_shape}
/// Creates an ellipse.
/// {@endtemplate}
class EllipseShape extends ChainShape {
/// {@macro ellipse_shape}
EllipseShape({
required this.center,
required this.majorRadius,
required this.minorRadius,
}) {
createChain(
calculateEllipse(
center: center,
majorRadius: majorRadius,
minorRadius: minorRadius,
),
);
}
/// The top left corner of the ellipse.
///
/// Where the initial painting begins.
// TODO(ruialonso): Change to use appropiate center.
final Vector2 center;
/// Major radius is specified by [majorRadius].
final double majorRadius;
/// Minor radius is specified by [minorRadius].
final double minorRadius;
/// Rotates the ellipse by a given [angle] in radians.
void rotate(double angle) {
vertices.map((vector) => vector..rotate(angle)).toList();
}
EllipseShape copyWith({
Vector2? center,
double? majorRadius,
double? minorRadius,
}) =>
EllipseShape(
center: center ?? this.center,
majorRadius: majorRadius ?? this.majorRadius,
minorRadius: minorRadius ?? this.minorRadius,
);
}

@ -0,0 +1,3 @@
export 'arc_shape.dart';
export 'bezier_curve_shape.dart';
export 'ellipse_shape.dart';

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

@ -0,0 +1,33 @@
name: pinball_components
description: Package with the UI game components for the Pinball Game
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=2.16.0 <3.0.0"
dependencies:
flame: ^1.1.0-releasecandidate.6
flame_forge2d: ^0.9.0-releasecandidate.6
flutter:
sdk: flutter
geometry:
path: ../geometry
dev_dependencies:
flame_test: ^1.1.0
flutter_test:
sdk: flutter
mocktail: ^0.2.0
very_good_analysis: ^2.4.0
flutter:
generate: true
assets:
- assets/images/
flutter_gen:
line_length: 80
assets:
package_parameter_enabled: true

@ -0,0 +1,127 @@
# Miscellaneous
*.class
*.lock
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/*
# Visual Studio Code related
.classpath
.project
.settings/
.vscode/*
# Flutter repo-specific
/bin/cache/
/bin/mingit/
/dev/benchmarks/mega_gallery/
/dev/bots/.recipe_deps
/dev/bots/android_tools/
/dev/docs/doc/
/dev/docs/flutter.docs.zip
/dev/docs/lib/
/dev/docs/pubspec.yaml
/dev/integration_tests/**/xcuserdata
/dev/integration_tests/**/Pods
/packages/flutter/coverage/
version
# packages file containing multi-root paths
.packages.generated
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
flutter_*.png
linked_*.ds
unlinked.ds
unlinked_spec.ds
.fvm/
# Android related
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/android/key.properties
**/android/.idea/
*.jks
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/.last_build_id
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# Coverage
coverage/
# Submodules
!pubspec.lock
packages/**/pubspec.lock
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Exceptions to the above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
!/dev/ci/**/Gemfile.lock
!.vscode/extensions.json
!.vscode/launch.json
!.idea/codeStyles/
!.idea/dictionaries/
!.idea/runConfigurations/

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6
channel: stable
project_type: app

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Very Good Ventures
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,164 @@
# Sandbox
![coverage][coverage_badge]
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
Generated by the [Very Good CLI][very_good_cli_link] 🤖
A sanbox application where components are showcased and developed in an isolated way
---
## Getting Started 🚀
This project contains 3 flavors:
- development
- staging
- production
To run the desired flavor either use the launch configuration in VSCode/Android Studio or use the following commands:
```sh
# Development
$ flutter run --flavor development --target lib/main_development.dart
# Staging
$ flutter run --flavor staging --target lib/main_staging.dart
# Production
$ flutter run --flavor production --target lib/main_production.dart
```
_\*Sandbox works on iOS, Android, Web, and Windows._
---
## Running Tests 🧪
To run all unit and widget tests use the following command:
```sh
$ flutter test --coverage --test-randomize-ordering-seed random
```
To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov).
```sh
# Generate Coverage Report
$ genhtml coverage/lcov.info -o coverage/
# Open Coverage Report
$ open coverage/index.html
```
---
## Working with Translations 🌐
This project relies on [flutter_localizations][flutter_localizations_link] and follows the [official internationalization guide for Flutter][internationalization_link].
### Adding Strings
1. To add a new localizable string, open the `app_en.arb` file at `lib/l10n/arb/app_en.arb`.
```arb
{
"@@locale": "en",
"counterAppBarTitle": "Counter",
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
}
}
```
2. Then add a new key/value and description
```arb
{
"@@locale": "en",
"counterAppBarTitle": "Counter",
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
},
"helloWorld": "Hello World",
"@helloWorld": {
"description": "Hello World Text"
}
}
```
3. Use the new string
```dart
import 'package:sandbox/l10n/l10n.dart';
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Text(l10n.helloWorld);
}
```
### Adding Supported Locales
Update the `CFBundleLocalizations` array in the `Info.plist` at `ios/Runner/Info.plist` to include the new locale.
```xml
...
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
</array>
...
```
### Adding Translations
1. For each supported locale, add a new ARB file in `lib/l10n/arb`.
```
├── l10n
│ ├── arb
│ │ ├── app_en.arb
│ │ └── app_es.arb
```
2. Add the translated strings to each `.arb` file:
`app_en.arb`
```arb
{
"@@locale": "en",
"counterAppBarTitle": "Counter",
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
}
}
```
`app_es.arb`
```arb
{
"@@locale": "es",
"counterAppBarTitle": "Contador",
"@counterAppBarTitle": {
"description": "Texto mostrado en la AppBar de la página del contador"
}
}
```
[coverage_badge]: coverage_badge.svg
[flutter_localizations_link]: https://api.flutter.dev/flutter/flutter_localizations/flutter_localizations-library.html
[internationalization_link]: https://flutter.dev/docs/development/accessibility-and-localization/internationalization
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli

@ -0,0 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
linter:
rules:
public_member_api_docs: false

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="20">
<linearGradient id="b" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
<stop offset="1" stop-opacity=".1" />
</linearGradient>
<clipPath id="a">
<rect width="102" height="20" rx="3" fill="#fff" />
</clipPath>
<g clip-path="url(#a)">
<path fill="#555" d="M0 0h59v20H0z" />
<path fill="#44cc11" d="M59 0h43v20H59z" />
<path fill="url(#b)" d="M0 0h102v20H0z" />
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
<text x="305" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="490">coverage</text>
<text x="305" y="140" transform="scale(.1)" textLength="490">coverage</text>
<text x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text>
<text x="795" y="140" transform="scale(.1)" textLength="330">100%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1,2 @@
export 'games.dart';
export 'methods.dart';

@ -0,0 +1,74 @@
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/input.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
class BasicGame extends Forge2DGame {
BasicGame() {
images.prefix = '';
}
}
abstract class LineGame extends BasicGame with PanDetector {
Vector2? _lineEnd;
@override
Future<void> onLoad() async {
await super.onLoad();
camera.followVector2(Vector2.zero());
unawaited(add(_PreviewLine()));
}
@override
void onPanStart(DragStartInfo info) {
_lineEnd = info.eventPosition.game;
}
@override
void onPanUpdate(DragUpdateInfo info) {
_lineEnd = info.eventPosition.game;
}
@override
void onPanEnd(DragEndInfo info) {
if (_lineEnd != null) {
final line = _lineEnd! - Vector2.zero();
onLine(line);
_lineEnd = null;
}
}
void onLine(Vector2 line);
}
class _PreviewLine extends PositionComponent with HasGameRef<LineGame> {
static final _previewLinePaint = Paint()
..color = Colors.pink
..strokeWidth = 0.2
..style = PaintingStyle.stroke;
Vector2? lineEnd;
@override
void update(double dt) {
super.update(dt);
lineEnd = gameRef._lineEnd?.clone()?..multiply(Vector2(1, -1));
}
@override
void render(Canvas canvas) {
super.render(canvas);
if (lineEnd != null) {
canvas.drawLine(
Vector2.zero().toOffset(),
lineEnd!.toOffset(),
_previewLinePaint,
);
}
}
}

@ -0,0 +1,3 @@
String buildSourceLink(String path) {
return 'https://github.com/VGVentures/pinball/tree/main/packages/pinball_components/sandbox/lib/stories/$path';
}

@ -0,0 +1,19 @@
// Copyright (c) 2022, Very Good Ventures
// https://verygood.ventures
//
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'package:dashbook/dashbook.dart';
import 'package:flutter/material.dart';
import 'package:sandbox/stories/effects/effects.dart';
import 'package:sandbox/stories/stories.dart';
void main() {
final dashbook = Dashbook(theme: ThemeData.dark());
addBallStories(dashbook);
addLayerStories(dashbook);
addEffectsStories(dashbook);
runApp(dashbook);
}

@ -0,0 +1,28 @@
import 'package:dashbook/dashbook.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:sandbox/common/common.dart';
import 'package:sandbox/stories/ball/ball_booster.dart';
import 'package:sandbox/stories/ball/basic.dart';
void addBallStories(Dashbook dashbook) {
dashbook.storiesOf('Ball')
..add(
'Basic',
(context) => GameWidget(
game: BasicBallGame(
color: context.colorProperty('color', Colors.blue),
),
),
codeLink: buildSourceLink('ball/basic.dart'),
info: BasicBallGame.info,
)
..add(
'Booster',
(context) => GameWidget(
game: BallBoosterExample(),
),
codeLink: buildSourceLink('ball/ball_booster.dart'),
info: BallBoosterExample.info,
);
}

@ -0,0 +1,16 @@
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/common/common.dart';
class BallBoosterExample extends LineGame {
static const info = '';
@override
void onLine(Vector2 line) {
final ball = Ball(baseColor: Colors.transparent);
add(ball);
ball.mounted.then((value) => ball.boost(line * -1 * 20));
}
}

@ -0,0 +1,22 @@
import 'package:flame/input.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/common/common.dart';
class BasicBallGame extends BasicGame with TapDetector {
BasicBallGame({required this.color});
static const info = '''
Basic example of how a Ball works, tap anywhere on the
screen to spawn a ball into the game.
''';
final Color color;
@override
void onTapUp(TapUpInfo info) {
add(
Ball(baseColor: color)..initialPosition = info.eventPosition.game,
);
}
}

@ -0,0 +1,13 @@
import 'package:dashbook/dashbook.dart';
import 'package:flame/game.dart';
import 'package:sandbox/common/common.dart';
import 'package:sandbox/stories/effects/fire_effect.dart';
void addEffectsStories(Dashbook dashbook) {
dashbook.storiesOf('Effects').add(
'Fire Effect',
(context) => GameWidget(game: FireEffectExample()),
codeLink: buildSourceLink('effects/fire_effect.dart'),
info: FireEffectExample.info,
);
}

@ -0,0 +1,46 @@
import 'package:flame/components.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/common/common.dart';
class FireEffectExample extends LineGame {
static const info = 'Demonstrate the fire trail effect '
'drag a line to define the trail direction';
@override
void onLine(Vector2 line) {
add(_EffectEmitter(line));
}
}
class _EffectEmitter extends Component {
_EffectEmitter(this.line) {
_direction = line.normalized();
_force = line.length;
}
static const _timerLimit = 2.0;
var _timer = _timerLimit;
final Vector2 line;
late Vector2 _direction;
late double _force;
@override
void update(double dt) {
super.update(dt);
if (_timer > 0) {
add(
FireEffect(
burstPower: (_timer / _timerLimit) * _force,
position: Vector2.zero(),
direction: _direction,
),
);
_timer -= dt;
} else {
removeFromParent();
}
}
}

@ -0,0 +1,98 @@
import 'package:flame/input.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/common/common.dart';
class BasicLayerGame extends BasicGame with TapDetector {
BasicLayerGame({required this.color});
static const info = '''
Basic example of how layers work with a Ball hitting other components,
tap anywhere on the screen to spawn a ball into the game.
''';
final Color color;
@override
Future<void> onLoad() async {
await add(BigSquare()..initialPosition = Vector2(30, -40));
await add(SmallSquare()..initialPosition = Vector2(50, -40));
await add(UnlayeredSquare()..initialPosition = Vector2(60, -40));
}
@override
void onTapUp(TapUpInfo info) {
add(
Ball(baseColor: color)..initialPosition = info.eventPosition.game,
);
}
}
class BigSquare extends BodyComponent with InitialPosition, Layered {
BigSquare() {
paint = Paint()
..color = const Color.fromARGB(255, 8, 218, 241)
..style = PaintingStyle.stroke;
layer = Layer.jetpack;
}
@override
Body createBody() {
final shape = PolygonShape()..setAsBoxXY(16, 16);
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()..position = initialPosition;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
@override
Future<void> onLoad() async {
await super.onLoad();
await addAll(
[
UnlayeredSquare()..initialPosition = Vector2.all(4),
SmallSquare()..initialPosition = Vector2.all(-4),
],
);
}
}
class SmallSquare extends BodyComponent with InitialPosition, Layered {
SmallSquare() {
paint = Paint()
..color = const Color.fromARGB(255, 27, 241, 8)
..style = PaintingStyle.stroke;
layer = Layer.board;
}
@override
Body createBody() {
final shape = PolygonShape()..setAsBoxXY(2, 2);
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()..position = initialPosition;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
class UnlayeredSquare extends BodyComponent with InitialPosition {
UnlayeredSquare() {
paint = Paint()
..color = const Color.fromARGB(255, 241, 8, 8)
..style = PaintingStyle.stroke;
}
@override
Body createBody() {
final shape = PolygonShape()..setAsBoxXY(3, 3);
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()..position = initialPosition;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}

@ -0,0 +1,18 @@
import 'package:dashbook/dashbook.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:sandbox/common/common.dart';
import 'package:sandbox/stories/layer/basic.dart';
void addLayerStories(Dashbook dashbook) {
dashbook.storiesOf('Layer').add(
'Layer',
(context) => GameWidget(
game: BasicLayerGame(
color: context.colorProperty('color', Colors.blue),
),
),
codeLink: buildSourceLink('layer/basic.dart'),
info: BasicLayerGame.info,
);
}

@ -0,0 +1,2 @@
export 'ball/ball.dart';
export 'layer/layer.dart';

@ -0,0 +1,453 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.0"
async:
dependency: transitive
description:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.2"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
characters:
dependency: transitive
description:
name: characters
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
clock:
dependency: transitive
description:
name: clock
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
collection:
dependency: transitive
description:
name: collection
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0"
dashbook:
dependency: "direct main"
description:
name: dashbook
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.7"
device_frame:
dependency: transitive
description:
name: device_frame
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
fake_async:
dependency: transitive
description:
name: fake_async
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.2"
file:
dependency: transitive
description:
name: file
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
flame:
dependency: "direct main"
description:
name: flame
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0-releasecandidate.6"
flame_forge2d:
dependency: "direct main"
description:
name: flame_forge2d
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.0-releasecandidate.6"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_colorpicker:
dependency: transitive
description:
name: flutter_colorpicker
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
flutter_markdown:
dependency: transitive
description:
name: flutter_markdown
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.9"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
forge2d:
dependency: transitive
description:
name: forge2d
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
geometry:
dependency: transitive
description:
path: "../../geometry"
relative: true
source: path
version: "1.0.0+1"
js:
dependency: transitive
description:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3"
json_annotation:
dependency: transitive
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.4.0"
markdown:
dependency: transitive
description:
name: markdown
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
matcher:
dependency: transitive
description:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.11"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.0"
ordered_set:
dependency: transitive
description:
name: ordered_set
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.5"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
pinball_components:
dependency: "direct main"
description:
path: ".."
relative: true
source: path
version: "1.0.0+1"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
process:
dependency: transitive
description:
name: process
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.4"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.13"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.11"
shared_preferences_ios:
dependency: transitive
description:
name: shared_preferences_ios
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.8"
typed_data:
dependency: transitive
description:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.20"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.15"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.15"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.9"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
vector_math:
dependency: transitive
description:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
very_good_analysis:
dependency: "direct dev"
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.4"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0+1"
sdks:
dart: ">=2.16.0 <3.0.0"
flutter: ">=2.10.0"

@ -0,0 +1,24 @@
name: sandbox
description: A sanbox application where components are showcased and developed in an isolated way
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=2.16.0 <3.0.0"
dependencies:
dashbook: ^0.1.7
flame: ^1.1.0-releasecandidate.6
flame_forge2d: ^0.9.0-releasecandidate.6
flutter:
sdk: flutter
pinball_components:
path: ../
dev_dependencies:
flutter_test:
sdk: flutter
very_good_analysis: ^2.4.0
flutter:
uses-material-design: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="sandbox">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>sandbox</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
var serviceWorkerVersion = null;
var scriptLoaded = false;
function loadMainDartJs() {
if (scriptLoaded) {
return;
}
scriptLoaded = true;
var scriptTag = document.createElement('script');
scriptTag.src = 'main.dart.js';
scriptTag.type = 'application/javascript';
document.body.append(scriptTag);
}
if ('serviceWorker' in navigator) {
// Service workers are supported. Use them.
window.addEventListener('load', function () {
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl)
.then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener('statechange', () => {
if (serviceWorker.state == 'activated') {
console.log('Installed new service worker.');
loadMainDartJs();
}
});
}
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing || reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log('New service worker available.');
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log('Loading app from service worker.');
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plaint <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
'Failed to load app from service worker. Falling back to plain <script> tag.',
);
loadMainDartJs();
}
}, 4000);
});
} else {
// Service workers not supported. Just drop the <script> tag.
loadMainDartJs();
}
</script>
</body>
</html>

@ -0,0 +1,35 @@
{
"name": "sandbox",
"short_name": "sandbox",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

@ -0,0 +1,2 @@
export 'mocks.dart';
export 'test_game.dart';

@ -0,0 +1,5 @@
import 'dart:ui';
import 'package:mocktail/mocktail.dart';
class MockCanvas extends Mock implements Canvas {}

@ -0,0 +1,7 @@
import 'package:flame_forge2d/flame_forge2d.dart';
class TestGame extends Forge2DGame {
TestGame() {
images.prefix = '';
}
}

@ -0,0 +1,186 @@
// ignore_for_file: cascade_invocations
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(TestGame.new);
group('Ball', () {
flameTester.test(
'loads correctly',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ready();
await game.ensureAdd(ball);
expect(game.contains(ball), isTrue);
},
);
group('body', () {
flameTester.test(
'is dynamic',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
expect(ball.body.bodyType, equals(BodyType.dynamic));
},
);
group('can be moved', () {
flameTester.test('by its weight', (game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
});
flameTester.test('by applying velocity', (game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
ball.body.gravityScale = 0;
ball.body.linearVelocity.setValues(10, 10);
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
});
});
});
group('fixture', () {
flameTester.test(
'exists',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
expect(ball.body.fixtures[0], isA<Fixture>());
},
);
flameTester.test(
'is dense',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
final fixture = ball.body.fixtures[0];
expect(fixture.density, greaterThan(0));
},
);
flameTester.test(
'shape is circular',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
final fixture = ball.body.fixtures[0];
expect(fixture.shape.shapeType, equals(ShapeType.circle));
expect(fixture.shape.radius, equals(1.5));
},
);
flameTester.test(
'has Layer.all as default filter maskBits',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ready();
await game.ensureAdd(ball);
await game.ready();
final fixture = ball.body.fixtures[0];
expect(fixture.filterData.maskBits, equals(Layer.board.maskBits));
},
);
});
group('stop', () {
group("can't be moved", () {
flameTester.test('by its weight', (game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
ball.stop();
game.update(1);
expect(ball.body.position, equals(ball.initialPosition));
});
});
flameTester.test('by applying velocity', (game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
ball.stop();
ball.body.linearVelocity.setValues(10, 10);
game.update(1);
expect(ball.body.position, equals(ball.initialPosition));
});
});
group('resume', () {
group('can move', () {
flameTester.test(
'by its weight when previously stopped',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
ball.stop();
ball.resume();
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
},
);
flameTester.test(
'by applying velocity when previously stopped',
(game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
ball.stop();
ball.resume();
ball.body.gravityScale = 0;
ball.body.linearVelocity.setValues(10, 10);
game.update(1);
expect(ball.body.position, isNot(equals(ball.initialPosition)));
},
);
});
});
group('boost', () {
flameTester.test('applies an impulse to the ball', (game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
expect(ball.body.linearVelocity, equals(Vector2.zero()));
ball.boost(Vector2.all(10));
expect(ball.body.linearVelocity.x, greaterThan(0));
expect(ball.body.linearVelocity.y, greaterThan(0));
});
flameTester.test('adds fire effect components to the game', (game) async {
final ball = Ball(baseColor: Colors.blue);
await game.ensureAdd(ball);
ball.boost(Vector2.all(10));
game.update(0);
await game.ready();
expect(game.children.whereType<FireEffect>().length, greaterThan(0));
});
});
});
}

@ -0,0 +1,55 @@
// ignore_for_file: cascade_invocations
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(TestGame.new);
setUpAll(() {
registerFallbackValue(Offset.zero);
registerFallbackValue(Paint());
});
group('FireEffect', () {
flameTester.test('is removed once its particles are done', (game) async {
await game.ensureAdd(
FireEffect(
burstPower: 1,
position: Vector2.zero(),
direction: Vector2.all(2),
),
);
await game.ready();
expect(game.children.whereType<FireEffect>().length, equals(1));
game.update(5);
await game.ready();
expect(game.children.whereType<FireEffect>().length, equals(0));
});
flameTester.test('render circles on the canvas', (game) async {
final effect = FireEffect(
burstPower: 1,
position: Vector2.zero(),
direction: Vector2.all(2),
);
await game.ensureAdd(effect);
await game.ready();
final canvas = MockCanvas();
effect.render(canvas);
verify(() => canvas.drawCircle(any(), any(), any()))
.called(greaterThan(0));
});
});
}

@ -3,7 +3,7 @@
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
class TestBodyComponent extends BodyComponent with InitialPosition {
@override

@ -4,9 +4,17 @@ import 'dart:math' as math;
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
class TestBodyComponent extends BodyComponent with Layered {
class TestLayeredBodyComponent extends BodyComponent with Layered {
@override
Body createBody() {
final fixtureDef = FixtureDef(CircleShape());
return world.createBody(BodyDef())..createFixture(fixtureDef);
}
}
class TestBodyComponent extends BodyComponent {
@override
Body createBody() {
final fixtureDef = FixtureDef(CircleShape());
@ -38,7 +46,7 @@ void main() {
});
test('correctly sets and gets', () {
final component = TestBodyComponent()..layer = Layer.jetpack;
final component = TestLayeredBodyComponent()..layer = Layer.jetpack;
expect(component.layer, Layer.jetpack);
});
@ -46,7 +54,7 @@ void main() {
'layers correctly before being loaded',
(game) async {
const expectedLayer = Layer.jetpack;
final component = TestBodyComponent()..layer = expectedLayer;
final component = TestLayeredBodyComponent()..layer = expectedLayer;
await game.ensureAdd(component);
// TODO(alestiago): modify once component.loaded is available.
await component.mounted;
@ -63,7 +71,7 @@ void main() {
'when multiple different sets',
(game) async {
const expectedLayer = Layer.launcher;
final component = TestBodyComponent()..layer = Layer.jetpack;
final component = TestLayeredBodyComponent()..layer = Layer.jetpack;
expect(component.layer, isNot(equals(expectedLayer)));
component.layer = expectedLayer;
@ -83,7 +91,7 @@ void main() {
'layers correctly after being loaded',
(game) async {
const expectedLayer = Layer.jetpack;
final component = TestBodyComponent();
final component = TestLayeredBodyComponent();
await game.ensureAdd(component);
component.layer = expectedLayer;
_expectLayerOnFixtures(
@ -98,7 +106,7 @@ void main() {
'when multiple different sets',
(game) async {
const expectedLayer = Layer.launcher;
final component = TestBodyComponent();
final component = TestLayeredBodyComponent();
await game.ensureAdd(component);
component.layer = Layer.jetpack;
@ -116,11 +124,55 @@ void main() {
'defaults to Layer.all '
'when no layer is given',
(game) async {
final component = TestBodyComponent();
final component = TestLayeredBodyComponent();
await game.ensureAdd(component);
expect(component.layer, equals(Layer.all));
},
);
flameTester.test(
'nested Layered children will keep their layer',
(game) async {
const parentLayer = Layer.jetpack;
const childLayer = Layer.board;
final component = TestLayeredBodyComponent()..layer = parentLayer;
final childComponent = TestLayeredBodyComponent()..layer = childLayer;
await component.add(childComponent);
await game.ensureAdd(component);
expect(childLayer, isNot(equals(parentLayer)));
for (final child in component.children) {
expect((child as TestLayeredBodyComponent).layer, equals(childLayer));
}
},
);
flameTester.test(
'nested children will keep their layer',
(game) async {
const parentLayer = Layer.jetpack;
final component = TestLayeredBodyComponent()..layer = parentLayer;
final childComponent = TestBodyComponent();
await component.add(childComponent);
await game.ensureAdd(component);
for (final child in component.children) {
expect(
(child as TestBodyComponent)
.body
.fixtures
.first
.filterData
.maskBits,
equals(Filter().maskBits),
);
}
},
);
});
group('LayerMaskBits', () {

@ -0,0 +1,66 @@
import 'dart:math' as math;
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/src/components/components.dart';
void main() {
group('ArcShape', () {
test('can be instantiated', () {
expect(
ArcShape(
center: Vector2.zero(),
arcRadius: 10,
angle: 2 * math.pi,
),
isNotNull,
);
});
group('copyWith', () {
test(
'copies correctly '
'when no argument specified', () {
final arcShape = ArcShape(
center: Vector2.zero(),
arcRadius: 10,
angle: 2 * math.pi,
);
final arcShapeCopied = arcShape.copyWith();
for (var index = 0; index < arcShape.vertices.length; index++) {
expect(
arcShape.vertices[index],
equals(arcShapeCopied.vertices[index]),
);
}
});
test(
'copies correctly '
'when all arguments specified', () {
final arcShapeExpected = ArcShape(
center: Vector2.all(10),
arcRadius: 15,
angle: 2 * math.pi,
);
final arcShapeCopied = ArcShape(
center: Vector2.zero(),
arcRadius: 10,
angle: math.pi,
).copyWith(
center: Vector2.all(10),
arcRadius: 15,
angle: 2 * math.pi,
);
for (var index = 0; index < arcShapeCopied.vertices.length; index++) {
expect(
arcShapeCopied.vertices[index],
equals(arcShapeExpected.vertices[index]),
);
}
});
});
});
}

@ -0,0 +1,51 @@
import 'dart:math' as math;
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/src/components/components.dart';
void main() {
group('BezierCurveShape', () {
final controlPoints = [
Vector2(0, 0),
Vector2(10, 0),
Vector2(0, 10),
Vector2(10, 10),
];
test('can be instantiated', () {
expect(
BezierCurveShape(
controlPoints: controlPoints,
),
isNotNull,
);
});
group('rotate', () {
test('returns vertices rotated', () {
const rotationAngle = 2 * math.pi;
final controlPoints = [
Vector2(0, 0),
Vector2(10, 0),
Vector2(0, 10),
Vector2(10, 10),
];
final bezierCurveShape = BezierCurveShape(
controlPoints: controlPoints,
);
final bezierCurveShapeRotated = BezierCurveShape(
controlPoints: controlPoints,
)..rotate(rotationAngle);
for (var index = 0; index < bezierCurveShape.vertices.length; index++) {
expect(
bezierCurveShape.vertices[index]..rotate(rotationAngle),
equals(bezierCurveShapeRotated.vertices[index]),
);
}
});
});
});
}

@ -0,0 +1,83 @@
import 'dart:math' as math;
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/src/components/components.dart';
void main() {
group('EllipseShape', () {
test('can be instantiated', () {
expect(
EllipseShape(
center: Vector2.zero(),
majorRadius: 10,
minorRadius: 8,
),
isNotNull,
);
});
group('rotate', () {
test('returns vertices rotated', () {
const rotationAngle = 2 * math.pi;
final ellipseShape = EllipseShape(
center: Vector2.zero(),
majorRadius: 10,
minorRadius: 8,
);
final ellipseShapeRotated = EllipseShape(
center: Vector2.zero(),
majorRadius: 10,
minorRadius: 8,
)..rotate(rotationAngle);
for (var index = 0; index < ellipseShape.vertices.length; index++) {
expect(
ellipseShape.vertices[index]..rotate(rotationAngle),
equals(ellipseShapeRotated.vertices[index]),
);
}
});
});
group('copyWith', () {
test('returns same shape when no properties are passed', () {
final ellipseShape = EllipseShape(
center: Vector2.zero(),
majorRadius: 10,
minorRadius: 8,
);
final ellipseShapeCopied = ellipseShape.copyWith();
for (var index = 0; index < ellipseShape.vertices.length; index++) {
expect(
ellipseShape.vertices[index],
equals(ellipseShapeCopied.vertices[index]),
);
}
});
test('returns object with updated properties when are passed', () {
final ellipseShapeExpected = EllipseShape(
center: Vector2.all(10),
majorRadius: 10,
minorRadius: 8,
);
final ellipseShapeCopied = EllipseShape(
center: Vector2.zero(),
majorRadius: 10,
minorRadius: 8,
).copyWith(center: Vector2.all(10));
for (var index = 0;
index < ellipseShapeCopied.vertices.length;
index++) {
expect(
ellipseShapeCopied.vertices[index],
equals(ellipseShapeExpected.vertices[index]),
);
}
});
});
});
}

@ -392,6 +392,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
pinball_components:
dependency: "direct main"
description:
path: "packages/pinball_components"
relative: true
source: path
version: "1.0.0+1"
pinball_theme:
dependency: "direct main"
description:

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

Loading…
Cancel
Save