diff --git a/.gitignore b/.gitignore index e47b373d..2d9c4dbe 100644 --- a/.gitignore +++ b/.gitignore @@ -130,4 +130,4 @@ app.*.map.json .firebase test/.test_runner.dart -web/__/firebase/init.js \ No newline at end of file +web/__/firebase/init.js diff --git a/assets/images/components/background.png b/assets/images/components/background.png new file mode 100644 index 00000000..8b8fdf77 Binary files /dev/null and b/assets/images/components/background.png differ diff --git a/lib/game/components/ball.dart b/lib/game/components/ball.dart index 756a8a97..aca2b154 100644 --- a/lib/game/components/ball.dart +++ b/lib/game/components/ball.dart @@ -1,33 +1,64 @@ 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_components/pinball_components.dart'; +/// {@template ball_type} +/// Specifies the type of [Ball]. +/// +/// Different [BallType]s are affected by different game mechanics. +/// {@endtemplate} +enum BallType { + /// A [Ball] spawned from the [Plunger]. + /// + /// [normal] balls decrease the [GameState.balls] when they fall through the + /// the [BottomWall]. + normal, + + /// A [Ball] that does not alter [GameState.balls]. + /// + /// For example, a [Ball] spawned by Dash in the [FlutterForest]. + extra, +} + /// {@template ball_blueprint} -/// [Blueprint] which cretes a ball game object +/// [Blueprint] which cretes a ball game object. /// {@endtemplate} class BallBlueprint extends Blueprint { /// {@macro ball_blueprint} - BallBlueprint({required this.position}); + BallBlueprint({ + required this.position, + required this.type, + }); - /// The initial position of the [Ball] + /// The initial position of the [Ball]. final Vector2 position; + /// {@macro ball_type} + final BallType type; + @override void build(PinballGame gameRef) { final baseColor = gameRef.theme.characterTheme.ballColor; - final ball = Ball(baseColor: baseColor)..add(BallController()); + final ball = Ball(baseColor: baseColor) + ..add( + BallController(type: type), + ); add(ball..initialPosition = position + Vector2(0, ball.size.y / 2)); } } -/// {@template ball} -/// A solid, [BodyType.dynamic] sphere that rolls and bounces along the -/// [PinballGame]. +/// {@template ball_controller} +/// Controller attached to a [Ball] that handles its game related logic. /// {@endtemplate} class BallController extends Component with HasGameRef { + /// {@macro ball_controller} + BallController({required this.type}); + + /// {@macro ball_type} + final BallType type; + /// Removes the [Ball] from a [PinballGame]; spawning a new [Ball] if /// any are left. /// @@ -35,9 +66,11 @@ class BallController extends Component with HasGameRef { /// a [BottomWall]. void lost() { parent?.shouldRemove = true; + // TODO(alestiago): Consider adding test for this logic once we remove the + // BallX extension. + if (type != BallType.normal) return; final bloc = gameRef.read()..add(const BallLost()); - final shouldBallRespwan = !bloc.state.isLastBall && !bloc.state.isGameOver; if (shouldBallRespwan) { gameRef.spawnBall(); diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index 34ef4d33..1f96120e 100644 --- a/lib/game/components/board.dart +++ b/lib/game/components/board.dart @@ -21,8 +21,16 @@ class Board extends Component { final flutterForest = FlutterForest(); + // TODO(alestiago): adjust positioning to real design. + final dino = ChromeDino() + ..initialPosition = Vector2( + PinballGame.boardBounds.center.dx + 25, + PinballGame.boardBounds.center.dy + 10, + ); + await addAll([ bottomGroup, + dino, flutterForest, ]); } diff --git a/lib/game/components/chrome_dino.dart b/lib/game/components/chrome_dino.dart new file mode 100644 index 00000000..5cf39b3c --- /dev/null +++ b/lib/game/components/chrome_dino.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart' hide Timer; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template chrome_dino} +/// Dinosaur that gobbles up a [Ball], swivel his head around, and shoots it +/// back out. +/// {@endtemplate} +class ChromeDino extends BodyComponent with InitialPosition { + /// {@macro chrome_dino} + ChromeDino() { + // TODO(alestiago): Remove once sprites are defined. + paint = Paint()..color = Colors.blue; + } + + /// The size of the dinosaur mouth. + static final size = Vector2(5, 2.5); + + /// Anchors the [ChromeDino] to the [RevoluteJoint] that controls its arc + /// motion. + Future<_ChromeDinoJoint> _anchorToJoint() async { + final anchor = _ChromeDinoAnchor(chromeDino: this); + await add(anchor); + + final jointDef = _ChromeDinoAnchorRevoluteJointDef( + chromeDino: this, + anchor: anchor, + ); + final joint = _ChromeDinoJoint(jointDef); + world.createJoint2(joint); + + return joint; + } + + @override + Future onLoad() async { + await super.onLoad(); + final joint = await _anchorToJoint(); + await add( + TimerComponent( + period: 1, + onTick: joint.swivel, + repeat: true, + ), + ); + } + + List _createFixtureDefs() { + final fixtureDefs = []; + + // TODO(alestiago): Subject to change when sprites are added. + final box = PolygonShape()..setAsBoxXY(size.x / 2, size.y / 2); + final fixtureDef = FixtureDef(box) + ..shape = box + ..density = 999 + ..friction = 0.3 + ..restitution = 0.1 + ..isSensor = true; + fixtureDefs.add(fixtureDef); + + // FIXME(alestiago): Investigate why adding these fixtures is considered as + // an invalid contact type. + // final upperEdge = EdgeShape() + // ..set( + // Vector2(-size.x / 2, -size.y / 2), + // Vector2(size.x / 2, -size.y / 2), + // ); + // final upperEdgeDef = FixtureDef(upperEdge)..density = 0.5; + // fixtureDefs.add(upperEdgeDef); + + // final lowerEdge = EdgeShape() + // ..set( + // Vector2(-size.x / 2, size.y / 2), + // Vector2(size.x / 2, size.y / 2), + // ); + // final lowerEdgeDef = FixtureDef(lowerEdge)..density = 0.5; + // fixtureDefs.add(lowerEdgeDef); + + // final rightEdge = EdgeShape() + // ..set( + // Vector2(size.x / 2, -size.y / 2), + // Vector2(size.x / 2, size.y / 2), + // ); + // final rightEdgeDef = FixtureDef(rightEdge)..density = 0.5; + // fixtureDefs.add(rightEdgeDef); + + return fixtureDefs; + } + + @override + Body createBody() { + final bodyDef = BodyDef() + ..gravityScale = 0 + ..position = initialPosition + ..type = BodyType.dynamic; + + final body = world.createBody(bodyDef); + _createFixtureDefs().forEach(body.createFixture); + + return body; + } +} + +/// {@template flipper_anchor} +/// [JointAnchor] positioned at the end of a [ChromeDino]. +/// {@endtemplate} +class _ChromeDinoAnchor extends JointAnchor { + /// {@macro flipper_anchor} + _ChromeDinoAnchor({ + required ChromeDino chromeDino, + }) { + initialPosition = Vector2( + chromeDino.body.position.x + ChromeDino.size.x / 2, + chromeDino.body.position.y, + ); + } +} + +/// {@template chrome_dino_anchor_revolute_joint_def} +/// Hinges a [ChromeDino] to a [_ChromeDinoAnchor]. +/// {@endtemplate} +class _ChromeDinoAnchorRevoluteJointDef extends RevoluteJointDef { + /// {@macro chrome_dino_anchor_revolute_joint_def} + _ChromeDinoAnchorRevoluteJointDef({ + required ChromeDino chromeDino, + required _ChromeDinoAnchor anchor, + }) { + initialize( + chromeDino.body, + anchor.body, + anchor.body.position, + ); + enableLimit = true; + // TODO(alestiago): Apply design angle value. + const angle = math.pi / 3.5; + lowerAngle = -angle / 2; + upperAngle = angle / 2; + + enableMotor = true; + // TODO(alestiago): Tune this values. + maxMotorTorque = motorSpeed = chromeDino.body.mass * 30; + } +} + +class _ChromeDinoJoint extends RevoluteJoint { + _ChromeDinoJoint(_ChromeDinoAnchorRevoluteJointDef def) : super(def); + + /// Sweeps the [ChromeDino] up and down repeatedly. + void swivel() { + setMotorSpeed(-motorSpeed); + } +} + +extension on World { + // TODO(alestiago): Remove once Forge2D supports custom joints. + void createJoint2(Joint joint) { + assert(!isLocked, ''); + + joints.add(joint); + + joint.bodyA.joints.add(joint); + joint.bodyB.joints.add(joint); + } +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index d2c7c72d..7c3347a6 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -3,6 +3,7 @@ export 'baseboard.dart'; export 'board.dart'; export 'board_side.dart'; export 'bonus_word.dart'; +export 'chrome_dino.dart'; export 'flipper.dart'; export 'flutter_forest.dart'; export 'jetpack_ramp.dart'; diff --git a/lib/game/components/flutter_forest.dart b/lib/game/components/flutter_forest.dart index 31ea8c2b..5b91ee40 100644 --- a/lib/game/components/flutter_forest.dart +++ b/lib/game/components/flutter_forest.dart @@ -35,6 +35,7 @@ class FlutterForest extends Component gameRef.addFromBlueprint( BallBlueprint( position: Vector2(17.2, 52.7), + type: BallType.extra, ), ); } diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index fdc1f332..648532cf 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -9,6 +9,7 @@ extension PinballGameAssetsX on PinballGame { await Future.wait([ images.load(components.Assets.images.ball.keyName), images.load(Assets.images.components.flipper.path), + images.load(Assets.images.components.background.path), images.load(Assets.images.components.spaceship.androidTop.path), images.load(Assets.images.components.spaceship.androidBottom.path), images.load(Assets.images.components.spaceship.lower.path), diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 7b630a41..0afdab99 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -2,13 +2,15 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:pinball/flame/blueprint.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball_theme/pinball_theme.dart'; +import 'package:pinball/gen/assets.gen.dart'; +import 'package:pinball_theme/pinball_theme.dart' hide Assets; class PinballGame extends Forge2DGame with FlameBloc, HasKeyboardHandlerComponents { @@ -48,7 +50,7 @@ class PinballGame extends Forge2DGame // Fix camera on the center of the board. camera - ..followVector2(Vector2.zero()) + ..followVector2(Vector2(0, -7.8)) ..zoom = size.y / 16; } @@ -101,15 +103,48 @@ class PinballGame extends Forge2DGame } void spawnBall() { - addFromBlueprint(BallBlueprint(position: plunger.body.position)); + addFromBlueprint( + BallBlueprint( + position: plunger.body.position, + type: BallType.normal, + ), + ); } } class DebugPinballGame extends PinballGame with TapDetector { DebugPinballGame({required PinballTheme theme}) : super(theme: theme); + @override + Future onLoad() async { + await super.onLoad(); + await _loadBackground(); + } + + // TODO(alestiago): Move to PinballGame once we have the real background + // component. + Future _loadBackground() async { + final sprite = await loadSprite( + Assets.images.components.background.path, + ); + final spriteComponent = SpriteComponent( + sprite: sprite, + size: Vector2(120, 160), + anchor: Anchor.center, + ) + ..position = Vector2(0, -7.8) + ..priority = -1; + + await add(spriteComponent); + } + @override void onTapUp(TapUpInfo info) { - addFromBlueprint(BallBlueprint(position: info.eventPosition.game)); + addFromBlueprint( + BallBlueprint( + position: info.eventPosition.game, + type: BallType.extra, + ), + ); } } diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 6e81fe77..8c228e16 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -3,6 +3,8 @@ /// FlutterGen /// ***************************************************** +// ignore_for_file: directives_ordering,unnecessary_import + import 'package:flutter/widgets.dart'; class $AssetsImagesGen { @@ -15,8 +17,14 @@ class $AssetsImagesGen { class $AssetsImagesComponentsGen { const $AssetsImagesComponentsGen(); + /// File path: assets/images/components/background.png + AssetGenImage get background => + const AssetGenImage('assets/images/components/background.png'); + + /// File path: assets/images/components/flipper.png AssetGenImage get flipper => const AssetGenImage('assets/images/components/flipper.png'); + $AssetsImagesComponentsSpaceshipGen get spaceship => const $AssetsImagesComponentsSpaceshipGen(); } @@ -24,14 +32,23 @@ class $AssetsImagesComponentsGen { class $AssetsImagesComponentsSpaceshipGen { const $AssetsImagesComponentsSpaceshipGen(); + /// File path: assets/images/components/spaceship/android-bottom.png AssetGenImage get androidBottom => const AssetGenImage( 'assets/images/components/spaceship/android-bottom.png'); + + /// File path: assets/images/components/spaceship/android-top.png AssetGenImage get androidTop => const AssetGenImage('assets/images/components/spaceship/android-top.png'); + + /// File path: assets/images/components/spaceship/lower.png AssetGenImage get lower => const AssetGenImage('assets/images/components/spaceship/lower.png'); + + /// File path: assets/images/components/spaceship/saucer.png AssetGenImage get saucer => const AssetGenImage('assets/images/components/spaceship/saucer.png'); + + /// File path: assets/images/components/spaceship/upper.png AssetGenImage get upper => const AssetGenImage('assets/images/components/spaceship/upper.png'); } diff --git a/test/game/components/board_test.dart b/test/game/components/board_test.dart index 5a4b95dc..f6304cec 100644 --- a/test/game/components/board_test.dart +++ b/test/game/components/board_test.dart @@ -86,6 +86,18 @@ void main() { expect(flutterForest.length, equals(1)); }, ); + + flameTester.test( + 'has one ChromeDino', + (game) async { + final board = Board(); + await game.ready(); + await game.ensureAdd(board); + + final chromeDino = board.descendants().whereType(); + expect(chromeDino.length, equals(1)); + }, + ); }); }); } diff --git a/test/game/components/chrome_dino_test.dart b/test/game/components/chrome_dino_test.dart new file mode 100644 index 00000000..af53ffea --- /dev/null +++ b/test/game/components/chrome_dino_test.dart @@ -0,0 +1,23 @@ +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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(Forge2DGame.new); + + group('ChromeDino', () { + flameTester.test( + 'loads correctly', + (game) async { + final chromeDino = ChromeDino(); + + await game.ready(); + await game.ensureAdd(chromeDino); + + expect(game.contains(chromeDino), isTrue); + }, + ); + }); +}