diff --git a/lib/game/assets_manager/cubit/assets_manager_cubit.dart b/lib/game/assets_manager/cubit/assets_manager_cubit.dart new file mode 100644 index 00000000..b97483d4 --- /dev/null +++ b/lib/game/assets_manager/cubit/assets_manager_cubit.dart @@ -0,0 +1,27 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'assets_manager_state.dart'; + +/// {@template assets_manager_cubit} +/// Cubit responsable for pre loading any game assets +/// {@endtemplate} +class AssetsManagerCubit extends Cubit { + /// {@macro assets_manager_cubit} + AssetsManagerCubit(List loadables) + : super( + AssetsManagerState.initial( + loadables: loadables, + ), + ); + + /// Loads the assets + Future load() async { + final all = state.loadables.map((loadable) async { + await loadable; + emit(state.copyWith(loaded: [...state.loaded, loadable])); + }).toList(); + + await Future.wait(all); + } +} diff --git a/lib/game/assets_manager/cubit/assets_manager_state.dart b/lib/game/assets_manager/cubit/assets_manager_state.dart new file mode 100644 index 00000000..8ef1e874 --- /dev/null +++ b/lib/game/assets_manager/cubit/assets_manager_state.dart @@ -0,0 +1,41 @@ +part of 'assets_manager_cubit.dart'; + +/// {@template assets_manager_state} +/// State used to load the game assets +/// {@endtemplate} +class AssetsManagerState extends Equatable { + /// {@macro assets_manager_state} + const AssetsManagerState({ + required this.loadables, + required this.loaded, + }); + + /// {@macro assets_manager_state} + const AssetsManagerState.initial({ + required List loadables, + }) : this(loadables: loadables, loaded: const []); + + /// List of futures to load + final List loadables; + + /// List of loaded futures + final List loaded; + + /// Returns a value between 0 and 1 to indicate the loading progress + double get progress => loaded.length / loadables.length; + + /// Returns a copy of this instance with the given parameters + /// updated + AssetsManagerState copyWith({ + List? loadables, + List? loaded, + }) { + return AssetsManagerState( + loadables: loadables ?? this.loadables, + loaded: loaded ?? this.loaded, + ); + } + + @override + List get props => [loaded, loadables]; +} diff --git a/lib/game/game.dart b/lib/game/game.dart index ad02533d..7de964eb 100644 --- a/lib/game/game.dart +++ b/lib/game/game.dart @@ -1,3 +1,4 @@ +export 'assets_manager/cubit/assets_manager_cubit.dart'; export 'bloc/game_bloc.dart'; export 'components/components.dart'; export 'game_assets.dart'; diff --git a/lib/game/game_assets.dart b/lib/game/game_assets.dart index 050b2cd3..678e25b6 100644 --- a/lib/game/game_assets.dart +++ b/lib/game/game_assets.dart @@ -4,9 +4,9 @@ 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 preLoadAssets() async { - await Future.wait([ + /// Returns a list of assets to be loaded + List preLoadAssets() { + return [ images.load(components.Assets.images.ball.keyName), images.load(components.Assets.images.flutterSignPost.keyName), images.load(components.Assets.images.flipper.left.keyName), @@ -47,6 +47,6 @@ extension PinballGameAssetsX on PinballGame { images.load(components.Assets.images.chromeDino.mouth.keyName), images.load(components.Assets.images.chromeDino.head.keyName), images.load(Assets.images.components.background.path), - ]); + ]; } } diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index e50eb2d7..b1f031c7 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -9,16 +9,41 @@ import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_theme/pinball_theme.dart'; class PinballGamePage extends StatelessWidget { - const PinballGamePage({Key? key, required this.theme}) : super(key: key); + const PinballGamePage({ + Key? key, + required this.theme, + required this.game, + }) : super(key: key); final PinballTheme theme; + final PinballGame game; - static Route route({required PinballTheme theme}) { + static Route route({ + required PinballTheme theme, + bool isDebugMode = kDebugMode, + }) { return MaterialPageRoute( - builder: (_) { - return BlocProvider( - create: (_) => GameBloc(), - child: PinballGamePage(theme: theme), + builder: (context) { + final audio = context.read(); + + final game = isDebugMode + ? DebugPinballGame(theme: theme, audio: audio) + : PinballGame(theme: theme, audio: audio); + + final pinballAudio = context.read(); + final loadables = [ + ...game.preLoadAssets(), + pinballAudio.load(), + ]; + + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => GameBloc()), + BlocProvider( + create: (_) => AssetsManagerCubit(loadables)..load(), + ), + ], + child: PinballGamePage(theme: theme, game: game), ); }, ); @@ -26,51 +51,19 @@ class PinballGamePage extends StatelessWidget { @override Widget build(BuildContext context) { - return PinballGameView(theme: theme); + return PinballGameView(theme: theme, game: game); } } -class PinballGameView extends StatefulWidget { +class PinballGameView extends StatelessWidget { const PinballGameView({ Key? key, required this.theme, - bool isDebugMode = kDebugMode, - }) : _isDebugMode = isDebugMode, - super(key: key); + required this.game, + }) : super(key: key); final PinballTheme theme; - final bool _isDebugMode; - - @override - State createState() => _PinballGameViewState(); -} - -class _PinballGameViewState extends State { - late PinballGame _game; - - @override - void initState() { - super.initState(); - - final audio = context.read(); - - _game = widget._isDebugMode - ? DebugPinballGame(theme: widget.theme, audio: audio) - : PinballGame(theme: widget.theme, audio: audio); - - // TODO(erickzanardo): Revisit this when we start to have more assets - // this could expose a Stream (maybe even a cubit?) so we could show the - // the loading progress with some fancy widgets. - _fetchAssets(); - } - - Future _fetchAssets() async { - final pinballAudio = context.read(); - await Future.wait([ - _game.preLoadAssets(), - pinballAudio.load(), - ]); - } + final PinballGame game; @override Widget build(BuildContext context) { @@ -84,24 +77,51 @@ class _PinballGameViewState extends State { builder: (_) { return GameOverDialog( score: state.score, - theme: widget.theme.characterTheme, + theme: theme.characterTheme, ); }, ); } }, - child: Stack( - children: [ - Positioned.fill( - child: GameWidget(game: _game), - ), - const Positioned( - top: 8, - left: 8, - child: GameHud(), + child: _GameView(game: game), + ); + } +} + +class _GameView extends StatelessWidget { + const _GameView({ + Key? key, + required PinballGame game, + }) : _game = game, + super(key: key); + + final PinballGame _game; + + @override + Widget build(BuildContext context) { + final loadingProgress = context.watch().state.progress; + + if (loadingProgress != 1) { + return Scaffold( + body: Center( + child: Text( + loadingProgress.toString(), ), - ], - ), + ), + ); + } + + return Stack( + children: [ + Positioned.fill( + child: GameWidget(game: _game), + ), + const Positioned( + top: 8, + left: 8, + child: GameHud(), + ), + ], ); } } diff --git a/test/game/assets_manager/cubit/assets_manager_cubit_test.dart b/test/game/assets_manager/cubit/assets_manager_cubit_test.dart new file mode 100644 index 00000000..d0afee34 --- /dev/null +++ b/test/game/assets_manager/cubit/assets_manager_cubit_test.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + group('AssetsManagerCubit', () { + final completer1 = Completer(); + final completer2 = Completer(); + + final future1 = completer1.future; + final future2 = completer2.future; + + blocTest( + 'emits the loaded on the order that they load', + build: () => AssetsManagerCubit([future1, future2]), + act: (cubit) { + cubit.load(); + completer2.complete(); + completer1.complete(); + }, + expect: () => [ + AssetsManagerState( + loadables: [future1, future2], + loaded: [future2], + ), + AssetsManagerState( + loadables: [future1, future2], + loaded: [future2, future1], + ), + ], + ); + }); +} diff --git a/test/game/assets_manager/cubit/assets_manager_state_test.dart b/test/game/assets_manager/cubit/assets_manager_state_test.dart new file mode 100644 index 00000000..12a42485 --- /dev/null +++ b/test/game/assets_manager/cubit/assets_manager_state_test.dart @@ -0,0 +1,145 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +void main() { + group('AssetsManagerState', () { + test('can be instantiated', () { + expect( + AssetsManagerState(loadables: const [], loaded: const []), + isNotNull, + ); + }); + + test('has the correct initial state', () { + final future = Future.value(); + expect( + AssetsManagerState.initial(loadables: [future]), + equals( + AssetsManagerState( + loadables: [future], + loaded: const [], + ), + ), + ); + }); + + group('progress', () { + final future1 = Future.value(); + final future2 = Future.value(); + + test('returns 0 when no future is loaded', () { + expect( + AssetsManagerState( + loadables: [future1, future2], + loaded: const [], + ).progress, + equals(0), + ); + }); + + test('returns the correct value when some of the futures are loaded', () { + expect( + AssetsManagerState( + loadables: [future1, future2], + loaded: [future1], + ).progress, + equals(0.5), + ); + }); + + test('returns the 1 when all futures are loaded', () { + expect( + AssetsManagerState( + loadables: [future1, future2], + loaded: [future1, future2], + ).progress, + equals(1), + ); + }); + }); + + group('copyWith', () { + final future = Future.value(); + + test('returns a copy with the updated loadables', () { + expect( + AssetsManagerState( + loadables: const [], + loaded: const [], + ).copyWith(loadables: [future]), + equals( + AssetsManagerState( + loadables: [future], + loaded: const [], + ), + ), + ); + }); + + test('returns a copy with the updated loaded', () { + expect( + AssetsManagerState( + loadables: const [], + loaded: const [], + ).copyWith(loaded: [future]), + equals( + AssetsManagerState( + loadables: const [], + loaded: [future], + ), + ), + ); + }); + }); + + test('supports value comparison', () { + final future1 = Future.value(); + final future2 = Future.value(); + + expect( + AssetsManagerState( + loadables: const [], + loaded: const [], + ), + equals( + AssetsManagerState( + loadables: const [], + loaded: const [], + ), + ), + ); + + expect( + AssetsManagerState( + loadables: [future1], + loaded: const [], + ), + isNot( + equals( + AssetsManagerState( + loadables: [future2], + loaded: const [], + ), + ), + ), + ); + + expect( + AssetsManagerState( + loadables: const [], + loaded: [future1], + ), + isNot( + equals( + AssetsManagerState( + loadables: const [], + loaded: [future2], + ), + ), + ), + ); + }); + }); +} diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index f16b8ef1..683b53e8 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -11,6 +11,7 @@ import '../../helpers/helpers.dart'; void main() { const theme = PinballTheme(characterTheme: DashTheme()); + final game = PinballGameTest(); group('PinballGamePage', () { testWidgets('renders PinballGameView', (tester) async { @@ -22,37 +23,107 @@ void main() { ); await tester.pumpApp( - PinballGamePage(theme: theme), + PinballGamePage(theme: theme, game: game), gameBloc: gameBloc, ); expect(find.byType(PinballGameView), findsOneWidget); }); - testWidgets('route returns a valid navigation route', (tester) async { - await tester.pumpApp( - Scaffold( - body: Builder( - builder: (context) { - return ElevatedButton( - onPressed: () { - Navigator.of(context) - .push(PinballGamePage.route(theme: theme)); - }, - child: const Text('Tap me'), - ); - }, + testWidgets( + 'renders the loading indicator while the assets load', + (tester) async { + final gameBloc = MockGameBloc(); + whenListen( + gameBloc, + Stream.value(const GameState.initial()), + initialState: const GameState.initial(), + ); + + final assetsManagerCubit = MockAssetsManagerCubit(); + final initialAssetsState = AssetsManagerState( + loadables: [Future.value()], + loaded: const [], + ); + whenListen( + assetsManagerCubit, + Stream.value(initialAssetsState), + initialState: initialAssetsState, + ); + + await tester.pumpApp( + PinballGamePage(theme: theme, game: game), + gameBloc: gameBloc, + assetsManagerCubit: assetsManagerCubit, + ); + expect(find.text('0.0'), findsOneWidget); + + final loadedAssetsState = AssetsManagerState( + loadables: [Future.value()], + loaded: [Future.value()], + ); + whenListen( + assetsManagerCubit, + Stream.value(loadedAssetsState), + initialState: loadedAssetsState, + ); + + await tester.pump(); + expect(find.byType(PinballGameView), findsOneWidget); + }, + ); + + group('route', () { + Future pumpRoute({ + required WidgetTester tester, + required bool isDebugMode, + }) async { + await tester.pumpApp( + Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context).push( + PinballGamePage.route( + theme: theme, + isDebugMode: isDebugMode, + ), + ); + }, + child: const Text('Tap me'), + ); + }, + ), ), - ), - ); + ); - await tester.tap(find.text('Tap me')); + await tester.tap(find.text('Tap me')); - // We can't use pumpAndSettle here because the page renders a Flame game - // which is an infinity animation, so it will timeout - await tester.pump(); // Runs the button action - await tester.pump(); // Runs the navigation + // We can't use pumpAndSettle here because the page renders a Flame game + // which is an infinity animation, so it will timeout + await tester.pump(); // Runs the button action + await tester.pump(); // Runs the navigation + } - expect(find.byType(PinballGamePage), findsOneWidget); + testWidgets('route creates the correct non debug game', (tester) async { + await pumpRoute(tester: tester, isDebugMode: false); + expect( + find.byWidgetPredicate( + (w) => w is PinballGameView && w.game is! DebugPinballGame, + ), + findsOneWidget, + ); + }); + + testWidgets('route creates the correct debug game', (tester) async { + await pumpRoute(tester: tester, isDebugMode: true); + expect( + find.byWidgetPredicate( + (w) => w is PinballGameView && w.game is DebugPinballGame, + ), + findsOneWidget, + ); + }); }); }); @@ -66,7 +137,7 @@ void main() { ); await tester.pumpApp( - PinballGameView(theme: theme), + PinballGameView(theme: theme, game: game), gameBloc: gameBloc, ); @@ -99,7 +170,7 @@ void main() { ); await tester.pumpApp( - const PinballGameView(theme: theme), + PinballGameView(theme: theme, game: game), gameBloc: gameBloc, ); await tester.pump(); @@ -107,45 +178,5 @@ void main() { expect(find.byType(GameOverDialog), findsOneWidget); }, ); - - testWidgets('renders the real game when not in debug mode', (tester) async { - final gameBloc = MockGameBloc(); - whenListen( - gameBloc, - Stream.value(const GameState.initial()), - initialState: const GameState.initial(), - ); - - await tester.pumpApp( - const PinballGameView(theme: theme, isDebugMode: false), - gameBloc: gameBloc, - ); - expect( - find.byWidgetPredicate( - (w) => w is GameWidget && w.game is! DebugPinballGame, - ), - findsOneWidget, - ); - }); - - testWidgets('renders the debug game when on debug mode', (tester) async { - final gameBloc = MockGameBloc(); - whenListen( - gameBloc, - Stream.value(const GameState.initial()), - initialState: const GameState.initial(), - ); - - await tester.pumpApp( - const PinballGameView(theme: theme), - gameBloc: gameBloc, - ); - expect( - find.byWidgetPredicate( - (w) => w is GameWidget && w.game is DebugPinballGame, - ), - findsOneWidget, - ); - }); }); } diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 748b48f3..2da91a25 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -74,3 +74,5 @@ class MockComponentSet extends Mock implements ComponentSet {} class MockDashNestBumper extends Mock implements DashNestBumper {} class MockPinballAudio extends Mock implements PinballAudio {} + +class MockAssetsManagerCubit extends Mock implements AssetsManagerCubit {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index 722dc44c..92e2c042 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -5,6 +5,7 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -26,11 +27,31 @@ PinballAudio _buildDefaultPinballAudio() { return audio; } +MockAssetsManagerCubit _buildDefaultAssetsManagerCubit() { + final cubit = MockAssetsManagerCubit(); + + final state = AssetsManagerState( + loadables: [Future.value()], + loaded: [ + Future.value(), + ], + ); + + whenListen( + cubit, + Stream.value(state), + initialState: state, + ); + + return cubit; +} + extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { MockNavigator? navigator, GameBloc? gameBloc, + AssetsManagerCubit? assetsManagerCubit, ThemeCubit? themeCubit, LeaderboardRepository? leaderboardRepository, PinballAudio? pinballAudio, @@ -54,6 +75,9 @@ extension PumpApp on WidgetTester { BlocProvider.value( value: gameBloc ?? MockGameBloc(), ), + BlocProvider.value( + value: assetsManagerCubit ?? _buildDefaultAssetsManagerCubit(), + ), ], child: MaterialApp( localizationsDelegates: const [