diff --git a/packages/pinball_flame/lib/pinball_flame.dart b/packages/pinball_flame/lib/pinball_flame.dart index 2d2e760b..709e7627 100644 --- a/packages/pinball_flame/lib/pinball_flame.dart +++ b/packages/pinball_flame/lib/pinball_flame.dart @@ -3,3 +3,4 @@ library pinball_flame; export 'src/blueprint.dart'; export 'src/component_controller.dart'; export 'src/keyboard_input_controller.dart'; +export 'src/sprite_animation.dart'; diff --git a/packages/pinball_flame/lib/src/sprite_animation.dart b/packages/pinball_flame/lib/src/sprite_animation.dart new file mode 100644 index 00000000..2990fb14 --- /dev/null +++ b/packages/pinball_flame/lib/src/sprite_animation.dart @@ -0,0 +1,102 @@ +// ignore_for_file: public_member_api_docs + +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/image_composition.dart'; +import 'package:flutter/material.dart' hide Animation; + +/// {@template flame.widgets.sprite_animation_widget} +/// A [StatelessWidget] that renders a [SpriteAnimation]. +/// {@endtemplate} +// TODO(arturplaczek): Remove when this PR will be merged. +// https://github.com/flame-engine/flame/pull/1552 +class SpriteAnimationWidget extends StatelessWidget { + /// {@macro flame.widgets.sprite_animation_widget} + const SpriteAnimationWidget({ + required this.controller, + this.anchor = Anchor.topLeft, + Key? key, + }) : super(key: key); + + /// The positioning [Anchor]. + final Anchor anchor; + + final SpriteAnimationController controller; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (_, __) { + return CustomPaint( + painter: SpritePainter( + controller.animation.getSprite(), + anchor, + ), + ); + }, + ); + } +} + +class SpriteAnimationController extends AnimationController { + SpriteAnimationController({ + required TickerProvider vsync, + required this.animation, + }) : super(vsync: vsync) { + duration = Duration(seconds: animation.totalDuration().ceil()); + } + + final SpriteAnimation animation; + + double? _lastUpdated; + + @override + void notifyListeners() { + super.notifyListeners(); + + final now = DateTime.now().millisecond.toDouble(); + final dt = max(0, (now - (_lastUpdated ?? 0)) / 1000); + animation.update(dt); + _lastUpdated = now; + } +} + +class SpritePainter extends CustomPainter { + SpritePainter( + this._sprite, + this._anchor, { + double angle = 0, + }) : _angle = angle; + + final Sprite _sprite; + final Anchor _anchor; + final double _angle; + + @override + bool shouldRepaint(SpritePainter oldDelegate) { + return oldDelegate._sprite != _sprite || + oldDelegate._anchor != _anchor || + oldDelegate._angle != _angle; + } + + @override + void paint(Canvas canvas, Size size) { + final boxSize = size.toVector2(); + final rate = boxSize.clone()..divide(_sprite.srcSize); + final minRate = min(rate.x, rate.y); + final paintSize = _sprite.srcSize * minRate; + final anchorPosition = _anchor.toVector2(); + final boxAnchorPosition = boxSize.clone()..multiply(anchorPosition); + final spriteAnchorPosition = anchorPosition..multiply(paintSize); + + canvas + ..translateVector(boxAnchorPosition..sub(spriteAnchorPosition)) + ..renderRotated( + _angle, + spriteAnchorPosition, + (canvas) => _sprite.render(canvas, size: paintSize), + ); + } +} diff --git a/packages/pinball_flame/test/src/sprite_animation_test.dart b/packages/pinball_flame/test/src/sprite_animation_test.dart new file mode 100644 index 00000000..e3b287de --- /dev/null +++ b/packages/pinball_flame/test/src/sprite_animation_test.dart @@ -0,0 +1,74 @@ +import 'package:flame/components.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_flame/pinball_flame.dart'; + +class MockSpriteAnimationController extends Mock + implements SpriteAnimationController {} + +class MockSpriteAnimation extends Mock implements SpriteAnimation {} + +class MockSprite extends Mock implements Sprite {} + +// TODO(arturplaczek): Remove when this PR will be merged. +// https://github.com/flame-engine/flame/pull/1552 + +void main() { + group('PinballSpriteAnimationWidget', () { + late SpriteAnimationController controller; + late SpriteAnimation animation; + late Sprite sprite; + + setUp(() { + controller = MockSpriteAnimationController(); + animation = MockSpriteAnimation(); + sprite = MockSprite(); + + when(() => controller.animation).thenAnswer((_) => animation); + + when(() => animation.totalDuration()).thenAnswer((_) => 1); + when(() => animation.getSprite()).thenAnswer((_) => sprite); + when(() => sprite.srcSize).thenAnswer((_) => Vector2(1, 1)); + when(() => sprite.srcSize).thenAnswer((_) => Vector2(1, 1)); + }); + + testWidgets('renders correctly', (tester) async { + await tester.pumpWidget( + SpriteAnimationWidget( + controller: controller, + ), + ); + + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect(find.byType(SpriteAnimationWidget), findsOneWidget); + }); + + test('SpriteAnimationController is updating animations', () { + SpriteAnimationController( + vsync: const TestVSync(), + animation: animation, + ).notifyListeners(); + + verify(() => animation.update(any())).called(1); + }); + + testWidgets('SpritePainter shouldRepaint returns true when Sprite changed', + (tester) async { + final spritePainter = SpritePainter( + sprite, + Anchor.center, + angle: 45, + ); + + final anotherPainter = SpritePainter( + sprite, + Anchor.center, + angle: 30, + ); + + expect(spritePainter.shouldRepaint(anotherPainter), isTrue); + }); + }); +}