diff --git a/lib/game/behaviors/behaviors.dart b/lib/game/behaviors/behaviors.dart index ae51fc09..f87b4f10 100644 --- a/lib/game/behaviors/behaviors.dart +++ b/lib/game/behaviors/behaviors.dart @@ -1,2 +1,3 @@ export 'bumper_noisy_behavior.dart'; +export 'camera_focusing_behavior.dart'; export 'scoring_behavior.dart'; diff --git a/lib/game/behaviors/camera_focusing_behavior.dart b/lib/game/behaviors/camera_focusing_behavior.dart new file mode 100644 index 00000000..9b753469 --- /dev/null +++ b/lib/game/behaviors/camera_focusing_behavior.dart @@ -0,0 +1,84 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +/// {@template focus_data} +/// Defines a [Camera] focus point. +/// {@endtemplate} +class FocusData { + /// {@template focus_data} + FocusData({ + required this.zoom, + required this.position, + }); + + /// The amount of zoom. + final double zoom; + + /// The position of the camera. + final Vector2 position; +} + +/// Changes the game focus when the [GameBloc] status changes. +class CameraFocusingBehavior extends Component + with ParentIsA, BlocComponent { + late final Map _foci; + + @override + bool listenWhen(GameState? previousState, GameState newState) { + return previousState?.status != newState.status; + } + + @override + void onNewState(GameState state) { + switch (state.status) { + case GameStatus.waiting: + break; + case GameStatus.playing: + _zoom(_foci['game']!); + break; + case GameStatus.gameOver: + _zoom(_foci['backbox']!); + break; + } + } + + @override + Future onLoad() async { + await super.onLoad(); + _foci = { + 'game': FocusData( + zoom: parent.size.y / 16, + position: Vector2(0, -7.8), + ), + 'waiting': FocusData( + zoom: parent.size.y / 18, + position: Vector2(0, -112), + ), + 'backbox': FocusData( + zoom: parent.size.y / 10, + position: Vector2(0, -111), + ), + }; + + _snap(_foci['waiting']!); + } + + void _snap(FocusData data) { + parent.camera + ..speed = 100 + ..followVector2(data.position) + ..zoom = data.zoom; + } + + void _zoom(FocusData data) { + final zoom = CameraZoom(value: data.zoom); + zoom.completed.then((_) { + parent.camera.moveTo(data.position); + }); + add(zoom); + } +} diff --git a/lib/game/components/camera_controller.dart b/lib/game/components/camera_controller.dart deleted file mode 100644 index 083e5745..00000000 --- a/lib/game/components/camera_controller.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame/game.dart'; -import 'package:pinball_components/pinball_components.dart'; -import 'package:pinball_flame/pinball_flame.dart'; - -/// Adds helpers methods to Flame's [Camera]. -extension CameraX on Camera { - /// Instantly apply the point of focus to the [Camera]. - void snapToFocus(FocusData data) { - followVector2(data.position); - zoom = data.zoom; - } - - /// Returns a [CameraZoom] that can be added to a [FlameGame]. - CameraZoom focusToCameraZoom(FocusData data) { - final zoom = CameraZoom(value: data.zoom); - zoom.completed.then((_) { - moveTo(data.position); - }); - return zoom; - } -} - -/// {@template focus_data} -/// Model class that defines a focus point of the camera. -/// {@endtemplate} -class FocusData { - /// {@template focus_data} - FocusData({ - required this.zoom, - required this.position, - }); - - /// The amount of zoom. - final double zoom; - - /// The position of the camera. - final Vector2 position; -} - -/// {@template camera_controller} -/// A [Component] that controls its game camera focus. -/// {@endtemplate} -class CameraController extends ComponentController { - /// {@macro camera_controller} - CameraController(FlameGame component) : super(component) { - final gameZoom = component.size.y / 16; - final waitingBackboxZoom = component.size.y / 18; - final gameOverBackboxZoom = component.size.y / 10; - - gameFocus = FocusData( - zoom: gameZoom, - position: Vector2(0, -7.8), - ); - waitingBackboxFocus = FocusData( - zoom: waitingBackboxZoom, - position: Vector2(0, -112), - ); - gameOverBackboxFocus = FocusData( - zoom: gameOverBackboxZoom, - position: Vector2(0, -111), - ); - - // Game starts with the camera focused on the [Backbox]. - component.camera - ..speed = 100 - ..snapToFocus(waitingBackboxFocus); - } - - /// Holds the data for the game focus point. - late final FocusData gameFocus; - - /// Holds the data for the waiting backbox focus point. - late final FocusData waitingBackboxFocus; - - /// Holds the data for the game over backbox focus point. - late final FocusData gameOverBackboxFocus; - - /// Move the camera focus to the game board. - void focusOnGame() { - component.add(component.camera.focusToCameraZoom(gameFocus)); - } - - /// Move the camera focus to the waiting backbox. - void focusOnWaitingBackbox() { - component.add(component.camera.focusToCameraZoom(waitingBackboxFocus)); - } - - /// Move the camera focus to the game over backbox. - void focusOnGameOverBackbox() { - component.add(component.camera.focusToCameraZoom(gameOverBackboxFocus)); - } -} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index c8a71cee..8f900475 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1,7 +1,6 @@ export 'android_acres/android_acres.dart'; export 'backbox/backbox.dart'; export 'bottom_group.dart'; -export 'camera_controller.dart'; export 'controlled_ball.dart'; export 'controlled_flipper.dart'; export 'controlled_plunger.dart'; diff --git a/lib/game/components/game_bloc_status_listener.dart b/lib/game/components/game_bloc_status_listener.dart index c0ddf6c0..167447e6 100644 --- a/lib/game/components/game_bloc_status_listener.dart +++ b/lib/game/components/game_bloc_status_listener.dart @@ -18,7 +18,6 @@ class GameBlocStatusListener extends Component break; case GameStatus.playing: gameRef.player.play(PinballAudio.backgroundMusic); - gameRef.firstChild()?.focusOnGame(); gameRef.overlays.remove(PinballGame.playButtonOverlay); break; case GameStatus.gameOver: @@ -27,7 +26,6 @@ class GameBlocStatusListener extends Component score: state.displayScore, character: gameRef.characterTheme, ); - gameRef.firstChild()!.focusOnGameOverBackbox(); break; } } diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index b022ea6a..438dd7da 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -48,8 +48,6 @@ class PinballGame extends PinballForge2DGame @override Future onLoad() async { - await add(CameraController(this)); - final machine = [ BoardBackgroundSpriteComponent(), Boundaries(), @@ -75,6 +73,7 @@ class PinballGame extends PinballForge2DGame await addAll( [ GameBlocStatusListener(), + CameraFocusingBehavior(), CanvasComponent( onSpritePainted: (paint) { if (paint.filterQuality != FilterQuality.medium) { diff --git a/test/game/behaviors/camera_focusing_behavior_test.dart b/test/game/behaviors/camera_focusing_behavior_test.dart new file mode 100644 index 00000000..ba6ea3a1 --- /dev/null +++ b/test/game/behaviors/camera_focusing_behavior_test.dart @@ -0,0 +1,126 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/behaviors/camera_focusing_behavior.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'CameraFocusingBehavior', + () { + final flameTester = FlameTester( + EmptyPinballTestGame.new, + ); + + test('can be instantiated', () { + expect( + CameraFocusingBehavior(), + isA(), + ); + }); + + flameTester.test('loads', (game) async { + final behavior = CameraFocusingBehavior(); + await game.ensureAdd(behavior); + expect(game.contains(behavior), isTrue); + }); + + flameTester.test( + 'changes focus when loaded', + (game) async { + final behavior = CameraFocusingBehavior(); + final previousZoom = game.camera.zoom; + expect(game.camera.follow, isNull); + + await game.ensureAdd(behavior); + + expect(game.camera.follow, isNotNull); + expect(game.camera.zoom, isNot(equals(previousZoom))); + }, + ); + + flameTester.test( + 'listenWhen only listens when status changes', + (game) async { + final behavior = CameraFocusingBehavior(); + const waiting = GameState.initial(); + final playing = + const GameState.initial().copyWith(status: GameStatus.playing); + final gameOver = + const GameState.initial().copyWith(status: GameStatus.gameOver); + + expect(behavior.listenWhen(waiting, waiting), isFalse); + expect(behavior.listenWhen(waiting, playing), isTrue); + expect(behavior.listenWhen(waiting, gameOver), isTrue); + + expect(behavior.listenWhen(playing, playing), isFalse); + expect(behavior.listenWhen(playing, waiting), isTrue); + expect(behavior.listenWhen(playing, gameOver), isTrue); + + expect(behavior.listenWhen(gameOver, gameOver), isFalse); + expect(behavior.listenWhen(gameOver, waiting), isTrue); + expect(behavior.listenWhen(gameOver, playing), isTrue); + }, + ); + + group('onNewState', () { + flameTester.test( + 'zooms when started playing', + (game) async { + final playing = + const GameState.initial().copyWith(status: GameStatus.playing); + + final behavior = CameraFocusingBehavior(); + await game.ensureAdd(behavior); + + behavior.onNewState(playing); + final previousPosition = game.camera.position.clone(); + await game.ready(); + + final zoom = behavior.children.whereType().single; + game.update(zoom.controller.duration!); + game.update(0); + + expect(zoom.controller.completed, isTrue); + expect( + game.camera.position, + isNot(equals(previousPosition)), + ); + }, + ); + + flameTester.test( + 'zooms when game is over', + (game) async { + final playing = const GameState.initial().copyWith( + status: GameStatus.gameOver, + ); + + final behavior = CameraFocusingBehavior(); + await game.ensureAdd(behavior); + + behavior.onNewState(playing); + final previousPosition = game.camera.position.clone(); + await game.ready(); + + final zoom = behavior.children.whereType().single; + game.update(zoom.controller.duration!); + game.update(0); + + expect(zoom.controller.completed, isTrue); + expect( + game.camera.position, + isNot(equals(previousPosition)), + ); + }, + ); + }); + }, + ); +} diff --git a/test/game/components/camera_controller_test.dart b/test/game/components/camera_controller_test.dart deleted file mode 100644 index 934f6340..00000000 --- a/test/game/components/camera_controller_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame/game.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball/game/components/camera_controller.dart'; -import 'package:pinball_components/pinball_components.dart'; - -void main() { - group('CameraController', () { - late FlameGame game; - late CameraController controller; - - setUp(() async { - game = FlameGame()..onGameResize(Vector2(100, 200)); - - controller = CameraController(game); - await game.ensureAdd(controller); - }); - - test('loads correctly', () async { - expect(game.firstChild(), isNotNull); - }); - - test('correctly calculates the zooms', () async { - expect(controller.gameFocus.zoom.toInt(), equals(12)); - expect(controller.waitingBackboxFocus.zoom.toInt(), equals(11)); - }); - - test('correctly sets the initial zoom and position', () async { - expect(game.camera.zoom, equals(controller.waitingBackboxFocus.zoom)); - expect( - game.camera.follow, - equals(controller.waitingBackboxFocus.position), - ); - }); - - group('focusOnGame', () { - test('changes the zoom', () async { - controller.focusOnGame(); - - await game.ready(); - final zoom = game.firstChild(); - expect(zoom, isNotNull); - expect(zoom?.value, equals(controller.gameFocus.zoom)); - }); - - test('moves the camera after the zoom is completed', () async { - controller.focusOnGame(); - await game.ready(); - final cameraZoom = game.firstChild()!; - final future = cameraZoom.completed; - - game.update(10); - game.update(0); // Ensure that the component was removed - - await future; - - expect(game.camera.position, Vector2(-4, -120)); - }); - }); - - group('focusOnWaitingBackbox', () { - test('changes the zoom', () async { - controller.focusOnWaitingBackbox(); - - await game.ready(); - final zoom = game.firstChild(); - expect(zoom, isNotNull); - expect(zoom?.value, equals(controller.waitingBackboxFocus.zoom)); - }); - - test('moves the camera after the zoom is completed', () async { - controller.focusOnWaitingBackbox(); - await game.ready(); - final cameraZoom = game.firstChild()!; - final future = cameraZoom.completed; - - game.update(10); - game.update(0); // Ensure that the component was removed - - await future; - - expect(game.camera.position, Vector2(-4.5, -121)); - }); - }); - - group('focusOnGameOverBackbox', () { - test('changes the zoom', () async { - controller.focusOnGameOverBackbox(); - - await game.ready(); - final zoom = game.firstChild(); - expect(zoom, isNotNull); - expect(zoom?.value, equals(controller.gameOverBackboxFocus.zoom)); - }); - - test('moves the camera after the zoom is completed', () async { - controller.focusOnGameOverBackbox(); - await game.ready(); - final cameraZoom = game.firstChild()!; - final future = cameraZoom.completed; - - game.update(10); - game.update(0); // Ensure that the component was removed - - await future; - - expect(game.camera.position, Vector2(-2.5, -117)); - }); - }); - }); -} diff --git a/test/game/components/game_bloc_status_listener_test.dart b/test/game/components/game_bloc_status_listener_test.dart index a614a2e2..73f47161 100644 --- a/test/game/components/game_bloc_status_listener_test.dart +++ b/test/game/components/game_bloc_status_listener_test.dart @@ -11,8 +11,6 @@ class _MockPinballGame extends Mock implements PinballGame {} class _MockBackbox extends Mock implements Backbox {} -class _MockCameraController extends Mock implements CameraController {} - class _MockActiveOverlaysNotifier extends Mock implements ActiveOverlaysNotifier {} @@ -46,20 +44,18 @@ void main() { group('onNewState', () { late PinballGame game; late Backbox backbox; - late CameraController cameraController; - late GameBlocStatusListener gameFlowController; + late GameBlocStatusListener gameBlocStatusListener; late PinballPlayer pinballPlayer; late ActiveOverlaysNotifier overlays; setUp(() { game = _MockPinballGame(); backbox = _MockBackbox(); - cameraController = _MockCameraController(); - gameFlowController = GameBlocStatusListener(); + gameBlocStatusListener = GameBlocStatusListener(); overlays = _MockActiveOverlaysNotifier(); pinballPlayer = _MockPinballPlayer(); - gameFlowController.mockGameRef(game); + gameBlocStatusListener.mockGameRef(game); when( () => backbox.requestInitials( @@ -67,22 +63,18 @@ void main() { character: any(named: 'character'), ), ).thenAnswer((_) async {}); - when(cameraController.focusOnWaitingBackbox).thenAnswer((_) async {}); - when(cameraController.focusOnGame).thenAnswer((_) async {}); when(() => overlays.remove(any())).thenAnswer((_) => true); when(() => game.descendants().whereType()) .thenReturn([backbox]); - when(game.firstChild).thenReturn(cameraController); when(() => game.overlays).thenReturn(overlays); when(() => game.characterTheme).thenReturn(DashTheme()); when(() => game.player).thenReturn(pinballPlayer); }); test( - 'changes the backbox display and camera correctly ' - 'when the game is over', + 'changes the backbox display when the game is over', () { final state = GameState( totalScore: 0, @@ -92,7 +84,7 @@ void main() { bonusHistory: const [], status: GameStatus.gameOver, ); - gameFlowController.onNewState(state); + gameBlocStatusListener.onNewState(state); verify( () => backbox.requestInitials( @@ -100,18 +92,16 @@ void main() { character: any(named: 'character'), ), ).called(1); - verify(cameraController.focusOnGameOverBackbox).called(1); }, ); test( - 'changes the backbox and camera correctly when it is not a game over', + 'changes the backbox when it is not a game over', () { - gameFlowController.onNewState( + gameBlocStatusListener.onNewState( GameState.initial().copyWith(status: GameStatus.playing), ); - verify(cameraController.focusOnGame).called(1); verify(() => overlays.remove(PinballGame.playButtonOverlay)) .called(1); }, @@ -120,7 +110,7 @@ void main() { test( 'plays the background music on start', () { - gameFlowController.onNewState( + gameBlocStatusListener.onNewState( GameState.initial().copyWith(status: GameStatus.playing), ); @@ -132,7 +122,7 @@ void main() { test( 'plays the game over voice over when it is game over', () { - gameFlowController.onNewState( + gameBlocStatusListener.onNewState( GameState.initial().copyWith(status: GameStatus.gameOver), );