diff --git a/lib/game/components/camera_controller.dart b/lib/game/components/camera_controller.dart new file mode 100644 index 00000000..f77a5044 --- /dev/null +++ b/lib/game/components/camera_controller.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flutter/services.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// A [Component] that controls its game camera focus +class CameraController extends Component with HasGameRef, KeyboardHandler { + /// The camera position for the board + static final zeroPosition = Vector2(0, -7.8); + + /// The camera position for the pinball panel + static final panelPosition = Vector2(0, -100.8); + + /// The zoom value for the game mode + late final double gameZoom; + + /// The zoom value for the panel mode + late final double panelZoom; + bool _isFocusingOnBoard = false; + + @override + Future onLoad() async { + await super.onLoad(); + + gameZoom = gameRef.size.y / 16; + panelZoom = gameRef.size.y / 12; + + // Game starts with the camera focused on the panel + gameRef.camera + ..speed = 200 + ..followVector2(panelPosition) + ..zoom = panelZoom; + } + + /// Move the camera focus to the game board + Future focusOnBoard() async { + final zoom = CameraZoom(value: gameZoom); + unawaited(gameRef.add(zoom)); + await zoom.completed; + gameRef.camera.moveTo(zeroPosition); + } + + // TODO(erickzanardo): Just for testing while + // we don't get the panel designs, which will be + // where this event will be generated from + @override + bool onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + if (!_isFocusingOnBoard) { + if (event is RawKeyUpEvent && + event.logicalKey == LogicalKeyboardKey.enter) { + _isFocusingOnBoard = true; + focusOnBoard(); + return true; + } + } + + return super.onKeyEvent(event, keysPressed); + } +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 3c1a4302..a155bded 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -2,6 +2,7 @@ export 'ball.dart'; export 'baseboard.dart'; export 'board.dart'; export 'bonus_word.dart'; +export 'camera_controller.dart'; export 'chrome_dino.dart'; export 'flipper_controller.dart'; export 'flutter_forest.dart'; diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 9673b2d2..95de2308 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -7,6 +7,8 @@ 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: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; @@ -41,7 +43,9 @@ class PinballGame extends Forge2DGame Future onLoad() async { _addContactCallbacks(); + unawaited(add(CameraController())); await _addGameBoundaries(); + unawaited(add(Panel(position: Vector2(0, -88)))); unawaited(add(Board())); unawaited(_addPlunger()); unawaited(_addBonusWord()); @@ -60,11 +64,6 @@ class PinballGame extends Forge2DGame ), ), ); - - // Fix camera on the center of the board. - camera - ..followVector2(Vector2(0, -7.8)) - ..zoom = size.y / 16; } void _addContactCallbacks() { diff --git a/packages/pinball_components/assets/images/panel.png b/packages/pinball_components/assets/images/panel.png new file mode 100644 index 00000000..1c9efdee Binary files /dev/null and b/packages/pinball_components/assets/images/panel.png differ diff --git a/packages/pinball_components/lib/gen/assets.gen.dart b/packages/pinball_components/lib/gen/assets.gen.dart index 54b0ff53..ae68b9ce 100644 --- a/packages/pinball_components/lib/gen/assets.gen.dart +++ b/packages/pinball_components/lib/gen/assets.gen.dart @@ -3,27 +3,18 @@ /// FlutterGen /// ***************************************************** -// ignore_for_file: directives_ordering,unnecessary_import - import 'package:flutter/widgets.dart'; class $AssetsImagesGen { const $AssetsImagesGen(); - /// File path: assets/images/ball.png AssetGenImage get ball => const AssetGenImage('assets/images/ball.png'); - $AssetsImagesFlipperGen get flipper => const $AssetsImagesFlipperGen(); - - /// File path: assets/images/flutter_sign_post.png AssetGenImage get flutterSignPost => const AssetGenImage('assets/images/flutter_sign_post.png'); - - /// File path: assets/images/spaceship_bridge.png + AssetGenImage get panel => const AssetGenImage('assets/images/panel.png'); AssetGenImage get spaceshipBridge => const AssetGenImage('assets/images/spaceship_bridge.png'); - - /// File path: assets/images/spaceship_saucer.png AssetGenImage get spaceshipSaucer => const AssetGenImage('assets/images/spaceship_saucer.png'); } @@ -31,11 +22,8 @@ class $AssetsImagesGen { class $AssetsImagesFlipperGen { const $AssetsImagesFlipperGen(); - /// File path: assets/images/flipper/left.png AssetGenImage get left => const AssetGenImage('assets/images/flipper/left.png'); - - /// File path: assets/images/flipper/right.png AssetGenImage get right => const AssetGenImage('assets/images/flipper/right.png'); } diff --git a/packages/pinball_components/lib/src/components/camera_zoom.dart b/packages/pinball_components/lib/src/components/camera_zoom.dart new file mode 100644 index 00000000..2244357f --- /dev/null +++ b/packages/pinball_components/lib/src/components/camera_zoom.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template camera_zoom} +/// Applies zoom to the camera of the game where this is added to +/// {@endtemplate} +class CameraZoom extends CurveComponent with HasGameRef { + /// {@macro camera_zoom} + CameraZoom({ + required this.value, + }) : super( + curve: Curves.easeOut, + duration: 0.4, + ); + + /// The total zoom value to be applied to the camera + final double value; + + late Tween _tween; + + @override + Future onLoad() async { + await super.onLoad(); + + _tween = Tween( + begin: gameRef.camera.zoom, + end: value, + ); + } + + @override + void apply(double progress) { + gameRef.camera.zoom = _tween.transform(progress); + } +} diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index c29f91a3..65fc1f04 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -1,11 +1,14 @@ export 'ball.dart'; export 'board_side.dart'; +export 'camera_zoom.dart'; +export 'curve.dart'; export 'fire_effect.dart'; export 'flipper.dart'; export 'flutter_sign_post.dart'; export 'initial_position.dart'; export 'joint_anchor.dart'; export 'layer.dart'; +export 'panel.dart'; export 'ramp_opening.dart'; export 'shapes/shapes.dart'; export 'spaceship.dart'; diff --git a/packages/pinball_components/lib/src/components/curve.dart b/packages/pinball_components/lib/src/components/curve.dart new file mode 100644 index 00000000..0b84acd3 --- /dev/null +++ b/packages/pinball_components/lib/src/components/curve.dart @@ -0,0 +1,46 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; + +/// {@template curve_component} +/// A simple component that runs for the given [duration] +/// following an animation curve. +/// {@endtemplate} +class CurveComponent extends Component { + /// {@macro curve_component} + CurveComponent({required this.curve, required this.duration}); + + /// Curve of this component + final Curve curve; + + /// How many seconds this curve lasts + final double duration; + + double _value = 0; + + final _completer = Completer(); + + @override + @mustCallSuper + void update(double dt) { + super.update(dt); + + _value += dt; + + final progress = curve.transform(min(_value, duration) / duration); + apply(progress); + + if (progress == 1) { + removeFromParent(); + _completer.complete(); + } + } + + /// Method called with the proggress (between 0 and 1) of the curve + /// Override this to apply side effects to the game/components + void apply(double progress) {} + + /// A future that completes once the curve has completed. + Future get completed => _completer.future; +} diff --git a/packages/pinball_components/lib/src/components/panel.dart b/packages/pinball_components/lib/src/components/panel.dart new file mode 100644 index 00000000..3ab11d58 --- /dev/null +++ b/packages/pinball_components/lib/src/components/panel.dart @@ -0,0 +1,27 @@ +import 'package:flame/components.dart'; +import 'package:pinball_components/gen/assets.gen.dart'; + +/// {@template panel} +/// The vertical panel of the pinball +/// {@endtemplate} +class Panel extends SpriteComponent with HasGameRef { + ///{@macro panel} + Panel({ + required Vector2 position, + }) : super( + // TODO(erickzanardo): https://github.com/flame-engine/flame/issues/1132 + position: position + ..clone().multiply( + Vector2(1, -1), + ), + size: Vector2(80, 60), + anchor: Anchor.bottomCenter, + ); + + @override + Future onLoad() async { + await super.onLoad(); + + sprite = await gameRef.loadSprite(Assets.images.panel.keyName); + } +} diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index 2df3c16c..f26e10e7 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -6,7 +6,9 @@ // https://opensource.org/licenses/MIT. import 'package:dashbook/dashbook.dart'; import 'package:flutter/material.dart'; +import 'package:sandbox/stories/camera/camera.dart'; import 'package:sandbox/stories/effects/effects.dart'; +import 'package:sandbox/stories/panel/panel.dart'; import 'package:sandbox/stories/spaceship/spaceship.dart'; import 'package:sandbox/stories/stories.dart'; @@ -18,5 +20,7 @@ void main() { addEffectsStories(dashbook); addFlipperStories(dashbook); addSpaceshipStories(dashbook); + addPanelStories(dashbook); + addCameraStories(dashbook); runApp(dashbook); } diff --git a/packages/pinball_components/sandbox/lib/stories/camera/camera.dart b/packages/pinball_components/sandbox/lib/stories/camera/camera.dart new file mode 100644 index 00000000..dea032e0 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/camera/camera.dart @@ -0,0 +1,15 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/camera/zoom.dart'; + +void addCameraStories(Dashbook dashbook) { + dashbook.storiesOf('Camera').add( + 'Zoom', + (context) => GameWidget( + game: ZoomCameraGame(), + ), + codeLink: buildSourceLink('panel/zoom.dart'), + info: ZoomCameraGame.info, + ); +} diff --git a/packages/pinball_components/sandbox/lib/stories/camera/zoom.dart b/packages/pinball_components/sandbox/lib/stories/camera/zoom.dart new file mode 100644 index 00000000..9cf30c3b --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/camera/zoom.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:flame/input.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; + +class ZoomCameraGame extends BasicGame with TapDetector { + static const info = 'Shows how the zoom works, tap to zoom in/out'; + + bool zoomed = false; + + @override + Future onLoad() async { + await super.onLoad(); + + camera.followVector2(Vector2.zero()); + unawaited(add(Panel(position: Vector2(0, -5)))); + } + + @override + void onTap() { + if (firstChild() == null) { + unawaited(add(CameraZoom(value: zoomed ? 10 : 20))); + zoomed = !zoomed; + } + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/panel/basic.dart b/packages/pinball_components/sandbox/lib/stories/panel/basic.dart new file mode 100644 index 00000000..b77c72db --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/panel/basic.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; + +class BasicPanelGame extends BasicGame { + static const info = 'Simple example which renders the Panel'; + + @override + Future onLoad() async { + await super.onLoad(); + + camera.followVector2(Vector2.zero()); + unawaited(add(Panel(position: Vector2(0, -5)))); + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/panel/panel.dart b/packages/pinball_components/sandbox/lib/stories/panel/panel.dart new file mode 100644 index 00000000..2db2264b --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/panel/panel.dart @@ -0,0 +1,15 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; +import 'package:sandbox/common/common.dart'; +import 'package:sandbox/stories/panel/basic.dart'; + +void addPanelStories(Dashbook dashbook) { + dashbook.storiesOf('Panel').add( + 'Basic', + (context) => GameWidget( + game: BasicPanelGame(), + ), + codeLink: buildSourceLink('panel/basic.dart'), + info: BasicPanelGame.info, + ); +} diff --git a/packages/pinball_components/test/src/components/camera_zoom_test.dart b/packages/pinball_components/test/src/components/camera_zoom_test.dart new file mode 100644 index 00000000..088d71ee --- /dev/null +++ b/packages/pinball_components/test/src/components/camera_zoom_test.dart @@ -0,0 +1,48 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('CameraZoom', () { + final tester = FlameTester(TestGame.new); + + tester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + game.camera.followVector2(Vector2.zero()); + game.camera.zoom = 1; + await game.ensureAdd(Panel(position: Vector2(0, 10))); + await game.ensureAdd(CameraZoom(value: 8)); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/camera_zoom/no_zoom.png'), + ); + + game.update(0.2); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('golden/camera_zoom/in_between.png'), + ); + + game.update(0.4); + await tester.pump(); + await expectLater( + find.byGame(), + matchesGoldenFile('golden/camera_zoom/finished.png'), + ); + game.update(0.1); + await tester.pump(); + + expect(game.firstChild(), isNull); + }, + ); + }); +} diff --git a/packages/pinball_components/test/src/components/curve_test.dart b/packages/pinball_components/test/src/components/curve_test.dart new file mode 100644 index 00000000..bda61c73 --- /dev/null +++ b/packages/pinball_components/test/src/components/curve_test.dart @@ -0,0 +1,31 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/animation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +void main() { + group('CurveComponent', () { + testWithFlameGame('is removed once it finishes', (game) async { + final curve = CurveComponent(curve: Curves.linear, duration: 1); + await game.ensureAdd(curve); + + expect(game.firstChild(), isNotNull); + + game.update(2); + game.update(0); + expect(game.firstChild(), isNull); + }); + + testWithFlameGame('completed completes once it finishes', (game) async { + final curve = CurveComponent(curve: Curves.linear, duration: 1); + await game.ensureAdd(curve); + + final completed = curve.completed; + + game.update(2); + expect(completed, completes); + }); + }); +} diff --git a/packages/pinball_components/test/src/components/golden/camera_zoom/finished.png b/packages/pinball_components/test/src/components/golden/camera_zoom/finished.png new file mode 100644 index 00000000..695fb0f9 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/camera_zoom/finished.png differ diff --git a/packages/pinball_components/test/src/components/golden/camera_zoom/in_between.png b/packages/pinball_components/test/src/components/golden/camera_zoom/in_between.png new file mode 100644 index 00000000..7196ad89 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/camera_zoom/in_between.png differ diff --git a/packages/pinball_components/test/src/components/golden/camera_zoom/no_zoom.png b/packages/pinball_components/test/src/components/golden/camera_zoom/no_zoom.png new file mode 100644 index 00000000..8bdb48a6 Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/camera_zoom/no_zoom.png differ diff --git a/packages/pinball_components/test/src/components/golden/panel.png b/packages/pinball_components/test/src/components/golden/panel.png new file mode 100644 index 00000000..be81aaaa Binary files /dev/null and b/packages/pinball_components/test/src/components/golden/panel.png differ diff --git a/packages/pinball_components/test/src/components/golden/spaceship.png b/packages/pinball_components/test/src/components/golden/spaceship.png index da665718..5e419c9c 100644 Binary files a/packages/pinball_components/test/src/components/golden/spaceship.png and b/packages/pinball_components/test/src/components/golden/spaceship.png differ diff --git a/packages/pinball_components/test/src/components/panel_test.dart b/packages/pinball_components/test/src/components/panel_test.dart new file mode 100644 index 00000000..fd33dfcb --- /dev/null +++ b/packages/pinball_components/test/src/components/panel_test.dart @@ -0,0 +1,29 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('Panel', () { + final tester = FlameTester(TestGame.new); + + tester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + game.camera.followVector2(Vector2.zero()); + game.camera.zoom = 5; + await game.ensureAdd(Panel(position: Vector2(0, 10))); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile('golden/panel.png'), + ); + }, + ); + }); +} diff --git a/test/game/components/camera_controller_test.dart b/test/game/components/camera_controller_test.dart new file mode 100644 index 00000000..3cba6d88 --- /dev/null +++ b/test/game/components/camera_controller_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/camera_controller.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('CameraController', () { + late FlameGame game; + late CameraController controller; + + setUp(() async { + game = FlameGame()..onGameResize(Vector2(100, 200)); + + controller = CameraController(); + await game.ensureAdd(controller); + }); + + test('loads correctly', () async { + expect(game.firstChild(), isNotNull); + }); + + test('correctly calculates the zooms', () async { + expect(controller.gameZoom.toInt(), equals(12)); + expect(controller.panelZoom.toInt(), equals(16)); + }); + + test('correctly sets the initial zoom and position', () async { + expect(game.camera.zoom, equals(controller.panelZoom)); + expect(game.camera.follow, equals(CameraController.panelPosition)); + }); + + group('focusOnBoard', () { + test('changes the zoom', () async { + unawaited(controller.focusOnBoard()); + + await game.ready(); + final zoom = game.firstChild(); + expect(zoom, isNotNull); + expect(zoom?.value, equals(controller.gameZoom)); + }); + + test('moves the camera after the zoom is completed', () async { + final future = controller.focusOnBoard(); + await game.ready(); + + game.update(10); + + await future; + + expect(game.camera.position, Vector2(-3, -106.8)); + }); + + test('moves the camera when enter is pressed', () async { + testRawKeyUpEvents([LogicalKeyboardKey.enter], (key) async { + controller.onKeyEvent(key, {}); + await game.ready(); + + game.update(10); + + expect(game.camera.position, Vector2(-3, -106.8)); + }); + }); + + test('does nothing when another key is pressed', () async { + testRawKeyUpEvents([LogicalKeyboardKey.keyA], (key) async { + final originalPosition = game.camera.position; + controller.onKeyEvent(key, {}); + await game.ready(); + + game.update(10); + + expect(game.camera.position, originalPosition); + }); + }); + }); + }); +}