diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 31d3b917..f91d9baf 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -7,6 +7,7 @@ export 'controlled_sparky_computer.dart'; export 'flutter_forest.dart'; export 'game_flow_controller.dart'; export 'plunger.dart'; +export 'score_effect_controller.dart'; export 'score_points.dart'; export 'sparky_fire_zone.dart'; export 'wall.dart'; diff --git a/lib/game/components/score_effect_controller.dart b/lib/game/components/score_effect_controller.dart new file mode 100644 index 00000000..7fafd4b5 --- /dev/null +++ b/lib/game/components/score_effect_controller.dart @@ -0,0 +1,45 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:pinball/flame/flame.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template score_effect_controller} +/// A [ComponentController] responsible for adding [ScoreText]s +/// on the game screen when the user earns points. +/// {@endtemplate} +class ScoreEffectController extends ComponentController + with BlocComponent { + /// {@macro score_effect_controller} + ScoreEffectController(PinballGame component) : super(component); + + int _lastScore = 0; + final _random = Random(); + + double _noise() { + return _random.nextDouble() * 5 * (_random.nextBool() ? -1 : 1); + } + + @override + bool listenWhen(GameState? previousState, GameState newState) { + return previousState?.score != newState.score; + } + + @override + void onNewState(GameState state) { + final newScore = state.score - _lastScore; + _lastScore = state.score; + + component.add( + ScoreText( + text: newScore.toString(), + position: Vector2( + _noise(), + _noise() + (-BoardDimensions.bounds.topCenter.dy + 10), + ), + ), + ); + } +} diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 27a56743..8d080b22 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -38,6 +38,7 @@ class PinballGame extends Forge2DGame Future onLoad() async { _addContactCallbacks(); + unawaited(add(ScoreEffectController(this))); unawaited(add(gameFlowController = GameFlowController(this))); unawaited(add(CameraController(this))); unawaited(add(Backboard(position: Vector2(0, -88)))); diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 90013646..b3b964f3 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -3,8 +3,6 @@ /// FlutterGen /// ***************************************************** -// ignore_for_file: directives_ordering,unnecessary_import - import 'package:flutter/widgets.dart'; class $AssetsImagesGen { @@ -17,11 +15,8 @@ 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/plunger.png AssetGenImage get plunger => const AssetGenImage('assets/images/components/plunger.png'); } diff --git a/packages/pinball_components/fonts/PixeloidMono-1G8ae.ttf b/packages/pinball_components/fonts/PixeloidMono-1G8ae.ttf new file mode 100644 index 00000000..a797c1e1 Binary files /dev/null and b/packages/pinball_components/fonts/PixeloidMono-1G8ae.ttf differ diff --git a/packages/pinball_components/fonts/PixeloidSans-nR3g1.ttf b/packages/pinball_components/fonts/PixeloidSans-nR3g1.ttf new file mode 100644 index 00000000..2f9a03b4 Binary files /dev/null and b/packages/pinball_components/fonts/PixeloidSans-nR3g1.ttf differ diff --git a/packages/pinball_components/fonts/PixeloidSansBold-RpeJo.ttf b/packages/pinball_components/fonts/PixeloidSansBold-RpeJo.ttf new file mode 100644 index 00000000..81194f5d Binary files /dev/null and b/packages/pinball_components/fonts/PixeloidSansBold-RpeJo.ttf differ diff --git a/packages/pinball_components/lib/gen/fonts.gen.dart b/packages/pinball_components/lib/gen/fonts.gen.dart new file mode 100644 index 00000000..b15f2dd0 --- /dev/null +++ b/packages/pinball_components/lib/gen/fonts.gen.dart @@ -0,0 +1,11 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +class FontFamily { + FontFamily._(); + + static const String pixeloidMono = 'PixeloidMono'; + static const String pixeloidSans = 'PixeloidSans'; +} diff --git a/packages/pinball_components/lib/gen/pinball_fonts.dart b/packages/pinball_components/lib/gen/pinball_fonts.dart new file mode 100644 index 00000000..c1b3c6fa --- /dev/null +++ b/packages/pinball_components/lib/gen/pinball_fonts.dart @@ -0,0 +1,16 @@ +import 'package:pinball_components/gen/fonts.gen.dart'; + +String _prefixFont(String font) { + return 'packages/pinball_components/$font'; +} + +/// Class with the fonts available on the pinball game +class PinballFonts { + PinballFonts._(); + + /// Mono variation of the Pixeloid font + static final String pixeloidMono = _prefixFont(FontFamily.pixeloidMono); + + /// Sans variation of the Pixeloid font + static final String pixeloidSans = _prefixFont(FontFamily.pixeloidMono); +} diff --git a/packages/pinball_components/lib/pinball_components.dart b/packages/pinball_components/lib/pinball_components.dart index b00b9d5b..2551b54e 100644 --- a/packages/pinball_components/lib/pinball_components.dart +++ b/packages/pinball_components/lib/pinball_components.dart @@ -1,4 +1,5 @@ library pinball_components; export 'gen/assets.gen.dart'; +export 'gen/pinball_fonts.dart'; export 'src/pinball_components.dart'; diff --git a/packages/pinball_components/lib/src/components/components.dart b/packages/pinball_components/lib/src/components/components.dart index b4ba70e2..4b2b41e7 100644 --- a/packages/pinball_components/lib/src/components/components.dart +++ b/packages/pinball_components/lib/src/components/components.dart @@ -17,6 +17,7 @@ export 'kicker.dart'; export 'launch_ramp.dart'; export 'layer.dart'; export 'ramp_opening.dart'; +export 'score_text.dart'; export 'shapes/shapes.dart'; export 'slingshot.dart'; export 'spaceship.dart'; diff --git a/packages/pinball_components/lib/src/components/score_text.dart b/packages/pinball_components/lib/src/components/score_text.dart new file mode 100644 index 00000000..01b26385 --- /dev/null +++ b/packages/pinball_components/lib/src/components/score_text.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template score_text} +/// A [TextComponent] that spawns at a given [position] with a moving animation. +/// {@endtemplate} +class ScoreText extends TextComponent { + /// {@macro score_text} + ScoreText({ + required String text, + required Vector2 position, + this.color = Colors.black, + }) : super( + text: text, + position: position, + anchor: Anchor.center, + priority: 100, + ); + + late final Effect _effect; + + /// The [text]'s [Color]. + final Color color; + + @override + Future onLoad() async { + textRenderer = TextPaint( + style: TextStyle( + fontFamily: PinballFonts.pixeloidMono, + color: color, + fontSize: 4, + ), + ); + + await add( + _effect = MoveEffect.by( + Vector2(0, -5), + EffectController(duration: 1), + ), + ); + } + + @override + void update(double dt) { + super.update(dt); + + if (_effect.controller.completed) { + removeFromParent(); + } + } +} diff --git a/packages/pinball_components/pubspec.yaml b/packages/pinball_components/pubspec.yaml index 64446faf..7a5d3e49 100644 --- a/packages/pinball_components/pubspec.yaml +++ b/packages/pinball_components/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flame_forge2d: ^0.10.0 flutter: sdk: flutter - geometry: + geometry: path: ../geometry @@ -24,6 +24,16 @@ dev_dependencies: flutter: generate: true + fonts: + - family: PixeloidSans + fonts: + - asset: fonts/PixeloidSans-nR3g1.ttf + - asset: fonts/PixeloidSansBold-RpeJo.ttf + weight: 700 + - family: PixeloidMono + fonts: + - asset: fonts/PixeloidMono-1G8ae.ttf + assets: - assets/images/ - assets/images/baseboard/ diff --git a/packages/pinball_components/sandbox/lib/main.dart b/packages/pinball_components/sandbox/lib/main.dart index 55a4dd88..1c9c9c25 100644 --- a/packages/pinball_components/sandbox/lib/main.dart +++ b/packages/pinball_components/sandbox/lib/main.dart @@ -28,5 +28,7 @@ void main() { addSpaceshipRampStories(dashbook); addSpaceshipRailStories(dashbook); addLaunchRampStories(dashbook); + addScoreTextStories(dashbook); + runApp(dashbook); } diff --git a/packages/pinball_components/sandbox/lib/stories/score_text/basic.dart b/packages/pinball_components/sandbox/lib/stories/score_text/basic.dart new file mode 100644 index 00000000..49b83863 --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/score_text/basic.dart @@ -0,0 +1,32 @@ +import 'dart:math'; + +import 'package:flame/input.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:sandbox/common/common.dart'; + +class ScoreTextBasicGame extends BasicGame with TapDetector { + static const info = ''' + Simple game to show how score text works, + + - Tap anywhere on the screen to spawn an text on the given location. +'''; + + final random = Random(); + + @override + Future onLoad() async { + camera.followVector2(Vector2.zero()); + } + + @override + void onTapUp(TapUpInfo info) { + add( + ScoreText( + text: random.nextInt(100000).toString(), + color: Colors.white, + position: info.eventPosition.game..multiply(Vector2(1, -1)), + ), + ); + } +} diff --git a/packages/pinball_components/sandbox/lib/stories/score_text/stories.dart b/packages/pinball_components/sandbox/lib/stories/score_text/stories.dart new file mode 100644 index 00000000..85caef1b --- /dev/null +++ b/packages/pinball_components/sandbox/lib/stories/score_text/stories.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/score_text/basic.dart'; + +void addScoreTextStories(Dashbook dashbook) { + dashbook.storiesOf('ScoreText').add( + 'Basic', + (context) => GameWidget( + game: ScoreTextBasicGame(), + ), + codeLink: buildSourceLink('score_text/basic.dart'), + info: ScoreTextBasicGame.info, + ); +} diff --git a/packages/pinball_components/sandbox/lib/stories/stories.dart b/packages/pinball_components/sandbox/lib/stories/stories.dart index feea2532..b4400500 100644 --- a/packages/pinball_components/sandbox/lib/stories/stories.dart +++ b/packages/pinball_components/sandbox/lib/stories/stories.dart @@ -7,6 +7,7 @@ export 'flipper/stories.dart'; export 'flutter_forest/stories.dart'; export 'launch_ramp/stories.dart'; export 'layer/stories.dart'; +export 'score_text/stories.dart'; export 'slingshot/stories.dart'; export 'spaceship/stories.dart'; export 'spaceship_rail/stories.dart'; diff --git a/packages/pinball_components/test/src/components/score_text_effects_test.dart b/packages/pinball_components/test/src/components/score_text_effects_test.dart new file mode 100644 index 00000000..7f828f1d --- /dev/null +++ b/packages/pinball_components/test/src/components/score_text_effects_test.dart @@ -0,0 +1,75 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('ScoreText', () { + final flameTester = FlameTester(TestGame.new); + + flameTester.testGameWidget( + 'renders correctly', + setUp: (game, tester) async { + game.camera.followVector2(Vector2.zero()); + await game.ensureAdd( + ScoreText( + text: '123', + position: Vector2.zero(), + color: Colors.white, + ), + ); + }, + verify: (game, tester) async { + final texts = game.descendants().whereType().length; + expect(texts, equals(1)); + }, + ); + + flameTester.testGameWidget( + 'has a movement effect', + setUp: (game, tester) async { + game.camera.followVector2(Vector2.zero()); + await game.ensureAdd( + ScoreText( + text: '123', + position: Vector2.zero(), + color: Colors.white, + ), + ); + + game.update(0.5); + await tester.pump(); + }, + verify: (game, tester) async { + final text = game.descendants().whereType().first; + expect(text.firstChild(), isNotNull); + }, + ); + + flameTester.testGameWidget( + 'is removed once finished', + setUp: (game, tester) async { + game.camera.followVector2(Vector2.zero()); + await game.ensureAdd( + ScoreText( + text: '123', + position: Vector2.zero(), + color: Colors.white, + ), + ); + + game.update(1); + game.update(0); // Ensure all component removals + }, + verify: (game, tester) async { + expect(game.children.length, equals(0)); + }, + ); + }); +} diff --git a/test/game/components/score_effect_controller_test.dart b/test/game/components/score_effect_controller_test.dart new file mode 100644 index 00000000..241f040b --- /dev/null +++ b/test/game/components/score_effect_controller_test.dart @@ -0,0 +1,115 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('ScoreEffectController', () { + late ScoreEffectController controller; + late PinballGame game; + + setUpAll(() { + registerFallbackValue(Component()); + }); + + setUp(() { + game = MockPinballGame(); + when(() => game.add(any())).thenAnswer((_) async {}); + + controller = ScoreEffectController(game); + }); + + group('listenWhen', () { + test('returns true when the user has earned points', () { + const previous = GameState.initial(); + const current = GameState( + score: 10, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [], + activatedDashNests: {}, + ); + expect(controller.listenWhen(previous, current), isTrue); + }); + + test( + 'returns true when the user has earned points and there was no ' + 'previous state', + () { + const current = GameState( + score: 10, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [], + activatedDashNests: {}, + ); + expect(controller.listenWhen(null, current), isTrue); + }, + ); + + test( + 'returns false when no points were earned', + () { + const current = GameState.initial(); + const previous = GameState.initial(); + expect(controller.listenWhen(previous, current), isFalse); + }, + ); + }); + + group('onNewState', () { + test( + 'adds a ScoreText with the correct score for the ' + 'first time', + () { + const state = GameState( + score: 10, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [], + activatedDashNests: {}, + ); + + controller.onNewState(state); + + final effect = + verify(() => game.add(captureAny())).captured.first as ScoreText; + + expect(effect.text, equals('10')); + }, + ); + + test('adds a ScoreTextEffect with the correct score', () { + controller.onNewState( + const GameState( + score: 10, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [], + activatedDashNests: {}, + ), + ); + + controller.onNewState( + const GameState( + score: 14, + balls: 3, + activatedBonusLetters: [], + bonusHistory: [], + activatedDashNests: {}, + ), + ); + + final effect = + verify(() => game.add(captureAny())).captured.last as ScoreText; + + expect(effect.text, equals('4')); + }); + }); + }); +}