diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index baf482b..71f38e6 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -17,10 +17,6 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { this.origin = Offset.zero, }); - static const int minParticleCount = 4; - static const int maxParticleCount = 5; - static const int minimumBurstDurationMs = 2000; - final int burstId; final VoidCallback onCompleted; final Random? random; @@ -55,43 +51,22 @@ class _PlayerDoubleTapHeartBurstState extends State _lifetime.start(); final random = widget.random ?? Random(); var delay = 0; - _particles = List.generate( - PlayerDoubleTapHeartBurst.minParticleCount + - random.nextInt( - PlayerDoubleTapHeartBurst.maxParticleCount - - PlayerDoubleTapHeartBurst.minParticleCount + - 1, - ), - (index) { - if (index > 0) delay += 80 + random.nextInt(41); - return _HeartParticle( - delay: delay, - duration: 1500 + random.nextInt(301), - travel: 100 + random.nextInt(31), - drift: -30 + random.nextInt(61), - size: (22 + random.nextInt(13)).toDouble(), - color: random.nextBool() - ? AppColors.doubleTapHeartRed - : AppColors.doubleTapHeartPink, - ); - }, - ); - var total = _particles + _particles = List.generate(6 + random.nextInt(3), (index) { + if (index > 0) delay += 40 + random.nextInt(21); + return _HeartParticle( + delay: delay, + duration: 700 + random.nextInt(201), + travel: 120 + random.nextInt(41), + drift: -30 + random.nextInt(61), + size: (22 + random.nextInt(13)).toDouble(), + color: random.nextBool() + ? AppColors.doubleTapHeartRed + : AppColors.doubleTapHeartPink, + ); + }); + final total = _particles .map((particle) => particle.delay + particle.duration) .reduce(max); - if (total < PlayerDoubleTapHeartBurst.minimumBurstDurationMs) { - final last = _particles.last; - final requiredDuration = - PlayerDoubleTapHeartBurst.minimumBurstDurationMs - last.delay; - final adjustedDuration = max( - last.duration, - requiredDuration, - ).clamp(1500, 1800).toInt(); - _particles[_particles.length - 1] = last.withDuration(adjustedDuration); - total = _particles - .map((particle) => particle.delay + particle.duration) - .reduce(max); - } if (kDebugMode) { debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); debugPrint( @@ -143,21 +118,20 @@ class _PlayerDoubleTapHeartBurstState extends State @override Widget build(BuildContext context) { return IgnorePointer( - child: SizedBox.shrink( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final elapsed = - _controller.value * _controller.duration!.inMilliseconds; - return Stack( - clipBehavior: Clip.none, - children: [ - for (var index = 0; index < _particles.length; index++) - _particle(_particles[index], elapsed, index), - ], - ); - }, - ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = + _controller.value * _controller.duration!.inMilliseconds; + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _particles.length; index++) + _particle(_particles[index], elapsed, index), + ], + ); + }, ), ); } @@ -171,8 +145,8 @@ class _PlayerDoubleTapHeartBurstState extends State final move = Curves.easeOutCubic.transform(progress); final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); return Positioned( - left: particle.drift * move - particle.size / 2, - top: -particle.size / 2 - particle.travel * move, + left: widget.origin.dx + particle.drift * move - particle.size / 2, + top: widget.origin.dy - particle.size / 2 - particle.travel * move, child: Opacity( opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), child: Transform.scale( @@ -205,15 +179,4 @@ class _HeartParticle { final int drift; final double size; final Color color; - - _HeartParticle withDuration(int nextDuration) { - return _HeartParticle( - delay: delay, - duration: nextDuration, - travel: travel, - drift: drift, - size: size, - color: color, - ); - } } diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 8d36c2b..7d01fe6 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -170,9 +170,7 @@ class _PlayerTapGestureRegionState extends State { final id = _nextHeartBurstId++; late final OverlayEntry entry; entry = OverlayEntry( - builder: (context) => Positioned( - left: origin.dx, - top: origin.dy, + builder: (context) => Positioned.fill( child: IgnorePointer( child: PlayerDoubleTapHeartBurst( key: ValueKey('heart-overlay-$id'), diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 98d7cc4..b7640b5 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -10,25 +10,32 @@ void main() { testWidgets('should_show_randomized_heart_particles_when_burst_starts', ( tester, ) async { + final random = _RecordingRandom(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: PlayerDoubleTapHeartBurst( burstId: 1, onCompleted: _noop, - random: Random(42), + random: random, ), ), ), ); - await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(const Duration(milliseconds: 500)); final hearts = find.byIcon(Icons.favorite_rounded); final count = hearts.evaluate().length; - expect(count, inInclusiveRange(4, 5)); + expect(count, inInclusiveRange(6, 8)); for (var index = 0; index < count; index++) { expect(_size(tester, index), inInclusiveRange(22, 34)); } + expect(random.intBounds.first, 3); + expect(random.intBounds.where((bound) => bound == 21), hasLength(7)); + expect(random.intBounds.where((bound) => bound == 201), hasLength(8)); + expect(random.intBounds.where((bound) => bound == 41), hasLength(8)); + expect(random.intBounds.where((bound) => bound == 61), hasLength(8)); + expect(random.intBounds.where((bound) => bound == 13), hasLength(8)); expect( tester .widgetList( @@ -50,11 +57,9 @@ void main() { closeTo(1.15, 1e-9), ); expect(PlayerDoubleTapHeartBurst.scaleForProgress(1), closeTo(0.2, 1e-9)); - expect(PlayerDoubleTapHeartBurst.minParticleCount, 4); - expect(PlayerDoubleTapHeartBurst.maxParticleCount, 5); }); - testWidgets('heart burst completes after at least two simulated seconds', ( + testWidgets('heart burst completes around 1.2 to 1.4 seconds', ( tester, ) async { var completed = false; @@ -64,23 +69,23 @@ void main() { home: Scaffold( body: PlayerDoubleTapHeartBurst( burstId: 2, - random: Random(7), + random: _RecordingRandom(), onCompleted: () => completed = true, ), ), ), ); - while (!completed && elapsedMs < 4000) { + while (!completed && elapsedMs < 2000) { await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(2000)); - expect(elapsedMs, lessThanOrEqualTo(2600)); + expect(elapsedMs, greaterThanOrEqualTo(1200)); + expect(elapsedMs, lessThanOrEqualTo(1400)); final count = find.byIcon(Icons.favorite_rounded).evaluate().length; - expect(count, inInclusiveRange(4, 5)); + expect(count, inInclusiveRange(6, 8)); }); } @@ -89,3 +94,19 @@ double _size(WidgetTester tester, int index) { } void _noop() {} + +class _RecordingRandom implements Random { + final List intBounds = []; + + @override + bool nextBool() => true; + + @override + double nextDouble() => 0.5; + + @override + int nextInt(int max) { + intBounds.add(max); + return max - 1; + } +}