diff --git a/lib/game/behaviors/behaviors.dart b/lib/game/behaviors/behaviors.dart index acfb81ee..243fff82 100644 --- a/lib/game/behaviors/behaviors.dart +++ b/lib/game/behaviors/behaviors.dart @@ -1,3 +1,4 @@ export 'ball_spawning_behavior.dart'; 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 0e68a2ea..b96b6a65 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 1984a523..167447e6 100644 --- a/lib/game/components/game_bloc_status_listener.dart +++ b/lib/game/components/game_bloc_status_listener.dart @@ -18,15 +18,14 @@ 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: + gameRef.player.play(PinballAudio.gameOverVoiceOver); gameRef.descendants().whereType().first.requestInitials( 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 82b48d59..bbab932b 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -43,8 +43,6 @@ class PinballGame extends PinballForge2DGame @override Future onLoad() async { - await add(CameraController(this)); - final machine = [ BoardBackgroundSpriteComponent(), Boundaries(), @@ -71,6 +69,7 @@ class PinballGame extends PinballForge2DGame [ GameBlocStatusListener(), BallSpawningBehavior(), + CameraFocusingBehavior(), CanvasComponent( onSpritePainted: (paint) { if (paint.filterQuality != FilterQuality.medium) { diff --git a/lib/game/view/widgets/score_view.dart b/lib/game/view/widgets/score_view.dart index 66233598..4c0c3f46 100644 --- a/lib/game/view/widgets/score_view.dart +++ b/lib/game/view/widgets/score_view.dart @@ -50,17 +50,19 @@ class _ScoreDisplay extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - l10n.score.toLowerCase(), - style: Theme.of(context).textTheme.subtitle1, - ), - const _ScoreText(), - const RoundCountDisplay(), - ], + return FittedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + l10n.score.toLowerCase(), + style: Theme.of(context).textTheme.subtitle1, + ), + const _ScoreText(), + const RoundCountDisplay(), + ], + ), ); } } @@ -72,11 +74,9 @@ class _ScoreText extends StatelessWidget { Widget build(BuildContext context) { final score = context.select((GameBloc bloc) => bloc.state.displayScore); - return FittedBox( - child: Text( - score.formatScore(), - style: Theme.of(context).textTheme.headline2, - ), + return Text( + score.formatScore(), + style: Theme.of(context).textTheme.headline1, ); } } diff --git a/packages/pinball_audio/assets/sfx/game_over_voice_over.mp3 b/packages/pinball_audio/assets/sfx/game_over_voice_over.mp3 new file mode 100644 index 00000000..2f2ae590 Binary files /dev/null and b/packages/pinball_audio/assets/sfx/game_over_voice_over.mp3 differ diff --git a/packages/pinball_audio/lib/gen/assets.gen.dart b/packages/pinball_audio/lib/gen/assets.gen.dart index 5bb8fea8..2bace523 100644 --- a/packages/pinball_audio/lib/gen/assets.gen.dart +++ b/packages/pinball_audio/lib/gen/assets.gen.dart @@ -16,6 +16,7 @@ class $AssetsSfxGen { String get bumperA => 'assets/sfx/bumper_a.mp3'; String get bumperB => 'assets/sfx/bumper_b.mp3'; + String get gameOverVoiceOver => 'assets/sfx/game_over_voice_over.mp3'; String get google => 'assets/sfx/google.mp3'; String get ioPinballVoiceOver => 'assets/sfx/io_pinball_voice_over.mp3'; } diff --git a/packages/pinball_audio/lib/src/pinball_audio.dart b/packages/pinball_audio/lib/src/pinball_audio.dart index a859a86f..dd3e8242 100644 --- a/packages/pinball_audio/lib/src/pinball_audio.dart +++ b/packages/pinball_audio/lib/src/pinball_audio.dart @@ -18,7 +18,10 @@ enum PinballAudio { backgroundMusic, /// IO Pinball voice over - ioPinballVoiceOver + ioPinballVoiceOver, + + /// Game over + gameOverVoiceOver, } /// Defines the contract of the creation of an [AudioPool]. @@ -30,20 +33,16 @@ typedef CreateAudioPool = Future Function( String? prefix, }); -/// Function that defines the contract for playing a single -/// audio +/// Defines the contract for playing a single audio. typedef PlaySingleAudio = Future Function(String); -/// Function that defines the contract for looping a single -/// audio +/// Defines the contract for looping a single audio. typedef LoopSingleAudio = Future Function(String); -/// Function that defines the contract for pre fetching an -/// audio +/// Defines the contract for pre fetching an audio. typedef PreCacheSingleAudio = Future Function(String); -/// Function that defines the contract for configuring -/// an [AudioCache] instance +/// Defines the contract for configuring an [AudioCache] instance. typedef ConfigureAudioCache = void Function(AudioCache); abstract class _Audio { @@ -164,6 +163,11 @@ class PinballPlayer { playSingleAudio: _playSingleAudio, path: Assets.sfx.ioPinballVoiceOver, ), + PinballAudio.gameOverVoiceOver: _SimplePlayAudio( + preCacheSingleAudio: _preCacheSingleAudio, + playSingleAudio: _playSingleAudio, + path: Assets.sfx.gameOverVoiceOver, + ), PinballAudio.bumper: _BumperAudio( createAudioPool: _createAudioPool, seed: _seed, diff --git a/packages/pinball_audio/test/src/pinball_audio_test.dart b/packages/pinball_audio/test/src/pinball_audio_test.dart index 8740cda0..b7760aa5 100644 --- a/packages/pinball_audio/test/src/pinball_audio_test.dart +++ b/packages/pinball_audio/test/src/pinball_audio_test.dart @@ -146,6 +146,11 @@ void main() { 'packages/pinball_audio/assets/sfx/io_pinball_voice_over.mp3', ), ).called(1); + verify( + () => preCacheSingleAudio.onCall( + 'packages/pinball_audio/assets/sfx/game_over_voice_over.mp3', + ), + ).called(1); verify( () => preCacheSingleAudio .onCall('packages/pinball_audio/assets/music/background.mp3'), @@ -227,6 +232,19 @@ void main() { }); }); + group('gameOverVoiceOver', () { + test('plays the correct file', () async { + await Future.wait(player.load()); + player.play(PinballAudio.gameOverVoiceOver); + + verify( + () => playSingleAudio.onCall( + 'packages/pinball_audio/${Assets.sfx.gameOverVoiceOver}', + ), + ).called(1); + }); + }); + group('backgroundMusic', () { test('plays the correct file', () async { await Future.wait(player.load()); diff --git a/packages/pinball_ui/lib/src/theme/pinball_text_style.dart b/packages/pinball_ui/lib/src/theme/pinball_text_style.dart index 5e0a7fa2..10383abf 100644 --- a/packages/pinball_ui/lib/src/theme/pinball_text_style.dart +++ b/packages/pinball_ui/lib/src/theme/pinball_text_style.dart @@ -45,7 +45,7 @@ abstract class PinballTextStyle { ); static const subtitle1 = TextStyle( - fontSize: 10, + fontSize: 12, fontFamily: _primaryFontFamily, package: _fontPackage, color: PinballColors.yellow, diff --git a/packages/pinball_ui/test/src/theme/pinball_text_style_test.dart b/packages/pinball_ui/test/src/theme/pinball_text_style_test.dart index 2af092b2..72cd66a6 100644 --- a/packages/pinball_ui/test/src/theme/pinball_text_style_test.dart +++ b/packages/pinball_ui/test/src/theme/pinball_text_style_test.dart @@ -26,9 +26,9 @@ void main() { expect(style.color, PinballColors.white); }); - test('subtitle1 has fontSize 10 and yellow color', () { + test('subtitle1 has fontSize 12 and yellow color', () { const style = PinballTextStyle.subtitle1; - expect(style.fontSize, 10); + expect(style.fontSize, 12); expect(style.color, PinballColors.yellow); }); 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 4bb313e6..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), ); @@ -128,6 +118,18 @@ void main() { .called(1); }, ); + + test( + 'plays the game over voice over when it is game over', + () { + gameBlocStatusListener.onNewState( + GameState.initial().copyWith(status: GameStatus.gameOver), + ); + + verify(() => pinballPlayer.play(PinballAudio.gameOverVoiceOver)) + .called(1); + }, + ); }); }); }