import 'dart:math'; import 'dart:async'; import 'package:flame/components.dart'; import 'package:flame/effects.dart'; import 'package:flame/events.dart'; import 'package:flame/collisions.dart'; import 'package:flutter/material.dart'; import '../../utils/colors.dart'; class Bubble extends SpriteComponent with HasGameReference, TapCallbacks, CollisionCallbacks { static const double startSize = 60.0; // Increased from 40.0 static const double maxSize = 120.0; // Increased from 100.0 static const double lifecycleDuration = 12.0; // seconds late String bubbleImagePath; late Color bubbleColor; bool isPopping = false; bool poppedByUser = false; Function(Bubble, bool)? onPop; double _age = 0.0; double _lifecycleProgress = 0.0; late double _targetSize; // Physics properties for collision and movement Vector2 velocity = Vector2.zero(); static const double maxSpeed = 50.0; static const double friction = 0.98; static const double collisionDamping = 0.7; double _floatTimer = 0.0; Bubble({ required Vector2 position, this.onPop, }) : super( size: Vector2.all(startSize), position: position, anchor: Anchor.center, ) { final random = Random(); _targetSize = (maxSize * 0.6) + (random.nextDouble() * maxSize * 0.4); // 60-100% of maxSize } @override Future onLoad() async { // Choose random bubble image and set corresponding color final bubbleData = _getRandomBubbleData(); bubbleImagePath = bubbleData['path']; bubbleColor = bubbleData['color']; // Load the sprite sprite = await Sprite.load(bubbleImagePath); await super.onLoad(); // Add circular collision hitbox add(CircleHitbox( radius: _targetSize / 2, anchor: Anchor.center, )); // Start with zero scale for smooth entrance animation scale = Vector2.zero(); // Add smooth entrance animation _addEntranceAnimation(); // Add varied, organic floating animations _addRandomFloatingAnimation(); _addRandomRotationAnimation(); // Initialize random velocity for natural movement final random = Random(); velocity = Vector2( (random.nextDouble() - 0.5) * 20, // -10 to +10 (random.nextDouble() - 0.5) * 20, // -10 to +10 ); } @override void update(double dt) { super.update(dt); if (isPopping) return; // Update age and lifecycle _age += dt; _lifecycleProgress = (_age / lifecycleDuration).clamp(0.0, 1.0); // Update size based on lifecycle (grow from startSize to targetSize) final currentSize = startSize + (_targetSize - startSize) * _lifecycleProgress; size = Vector2.all(currentSize); // Update opacity based on lifecycle (fade out towards the end) final opacityFactor = 1.0 - (_lifecycleProgress * 0.6); // Fade to 40% opacity opacity = opacityFactor.clamp(0.4, 1.0); // Physics-based movement _updatePhysics(dt); // Keep bubble within screen bounds _keepInBounds(); // Auto-pop when lifecycle is complete if (_lifecycleProgress >= 1.0) { pop(userTriggered: false); } } void _updatePhysics(double dt) { // Apply velocity to position position.add(velocity * dt); // Apply friction velocity.scale(friction); // Add periodic floating effects _floatTimer += dt; if (_floatTimer > 2.0) { _floatTimer = 0.0; final random = Random(); final floatForce = Vector2( (random.nextDouble() - 0.5) * 8, // -4 to +4 horizontal (random.nextDouble() - 0.5) * 8, // -4 to +4 vertical ); velocity.add(floatForce); } // Clamp velocity to max speed if (velocity.length > maxSpeed) { velocity.normalize(); velocity.scale(maxSpeed); } } void _keepInBounds() { const margin = 60.0; final gameSize = game.size; // Bounce off screen edges if (position.x < margin) { position.x = margin; velocity.x = velocity.x.abs(); // Bounce right } else if (position.x > gameSize.x - margin) { position.x = gameSize.x - margin; velocity.x = -velocity.x.abs(); // Bounce left } if (position.y < margin + 100) { // Account for UI at top position.y = margin + 100; velocity.y = velocity.y.abs(); // Bounce down } else if (position.y > gameSize.y - margin) { position.y = gameSize.y - margin; velocity.y = -velocity.y.abs(); // Bounce up } } Map _getRandomBubbleData() { final random = Random(); final bubbleTypes = [ { 'path': 'bubble_blue.png', 'color': ZenColors.defaultLink, }, { 'path': 'bubble_cyan.png', 'color': ZenColors.buttonBackground, }, { 'path': 'bubble_green.png', 'color': const Color(0xFF00FF80), }, { 'path': 'bubble_purple.png', 'color': ZenColors.lightModeHover, }, ]; return bubbleTypes[random.nextInt(bubbleTypes.length)]; } void _addRandomFloatingAnimation() { final random = Random(); // Add a small upward bias to the initial velocity velocity.y += -5 - random.nextDouble() * 10; // -5 to -15 upward bias // The floating effect will now be handled by the physics system // and periodic velocity changes in the update method } void _addEntranceAnimation() { // Smooth scale-in animation with bounce effect add( ScaleEffect.to( Vector2.all(1.0), EffectController( duration: 0.6, curve: Curves.elasticOut, ), ), ); // Optional fade-in animation opacity = 0.0; add( OpacityEffect.to( 1.0, EffectController( duration: 0.4, curve: Curves.easeOut, ), ), ); } void _addRandomRotationAnimation() { final random = Random(); // 30% chance for rotation animation if (random.nextDouble() < 0.3) { // Small random rotation between -0.2 and 0.2 radians final rotationAngle = (random.nextDouble() - 0.5) * 0.4; // Random rotation duration between 3 and 8 seconds final rotationDuration = 3.0 + random.nextDouble() * 5.0; // Random delay final delay = random.nextDouble() * 3.0; Future.delayed(Duration(milliseconds: (delay * 1000).round()), () { if (isMounted) { add( RotateEffect.by( rotationAngle, EffectController( duration: rotationDuration, alternate: true, infinite: true, curve: Curves.easeInOut, ), ), ); } }); } } @override bool onTapDown(TapDownEvent event) { if (!isPopping) { pop(userTriggered: true); } return true; } void pop({bool userTriggered = false}) { if (isPopping) return; isPopping = true; poppedByUser = userTriggered; // Create exciting multi-stage pop animation // Stage 1: Quick expand (0.1s) final expandEffect = ScaleEffect.to( Vector2.all(1.8), EffectController(duration: 0.1, curve: Curves.easeOut), ); // Stage 2: Slight compression (0.05s) final compressEffect = ScaleEffect.to( Vector2.all(0.8), EffectController(duration: 0.05, curve: Curves.easeIn), ); // Stage 3: Final explosion (0.15s) final explodeEffect = ScaleEffect.to( Vector2.all(2.5), EffectController(duration: 0.15, curve: Curves.easeOut), ); // Fade out effect final fadeEffect = OpacityEffect.to( 0.0, EffectController(duration: 0.3, curve: Curves.easeIn), ); // Rotation effect for more dynamic feel final rotateEffect = RotateEffect.by( 0.5, // Half rotation EffectController(duration: 0.3), ); // Chain the scale effects add(expandEffect); expandEffect.onComplete = () { if (isMounted) { add(compressEffect); compressEffect.onComplete = () { if (isMounted) { add(explodeEffect); } }; } }; add(fadeEffect); add(rotateEffect); // Create enhanced particle effects _createExcitingPopParticles(); // Notify parent and remove bubble onPop?.call(this, userTriggered); Future.delayed(const Duration(milliseconds: 300), () { if (isMounted) { removeFromParent(); } }); } void _createExcitingPopParticles() { final random = Random(); const particleCount = 4; // Further reduced for better performance // Use object pooling approach - create fewer, simpler particles for (int i = 0; i < particleCount; i++) { final angle = (i / particleCount) * 2 * pi; // Simplified particle creation final baseSpeed = 50 + random.nextDouble() * 30; // 50-80 speed final particleVelocity = Vector2( cos(angle) * baseSpeed, sin(angle) * baseSpeed, ); // Fixed particle size for performance const particleSize = 3.0; // Simplified particle color final particleColor = bubbleColor.withValues(alpha: 0.8); final particle = CircleComponent( radius: particleSize, position: position.clone(), paint: Paint() ..color = particleColor ..style = PaintingStyle.fill, ); parent?.add(particle); // Combine effects for better performance final combinedEffect = MoveEffect.by( particleVelocity + Vector2(0, 40), // Combined movement + gravity EffectController(duration: 0.3, curve: Curves.easeOut), // Faster animation ); particle.add(combinedEffect); // Single fade effect instead of multiple effects particle.add( OpacityEffect.to( 0.0, EffectController(duration: 0.3, curve: Curves.easeIn), ), ); // Faster cleanup Future.delayed(const Duration(milliseconds: 300), () { if (particle.isMounted) { particle.removeFromParent(); } }); } } /// Add shake effect to this bubble (called when phone is shaken) void addShakeEffect() { if (isPopping) return; final random = Random(); // Random shake direction final shakeDirection = Vector2( (random.nextDouble() - 0.5) * 60, // -30 to +30 (random.nextDouble() - 0.5) * 60, // -30 to +30 ); // Add shake movement effect add( MoveEffect.by( shakeDirection, EffectController( duration: 0.4, alternate: true, curve: Curves.elasticOut, ), ), ); // Add scale bounce effect add( ScaleEffect.by( Vector2.all(0.3), EffectController( duration: 0.2, alternate: true, curve: Curves.elasticOut, ), ), ); // Add slight rotation effect add( RotateEffect.by( (random.nextDouble() - 0.5) * 0.4, // Random rotation EffectController( duration: 0.3, alternate: true, curve: Curves.elasticOut, ), ), ); } // Collision detection methods @override bool onCollision(Set intersectionPoints, PositionComponent other) { super.onCollision(intersectionPoints, other); if (other is Bubble && !isPopping && !other.isPopping) { _handleBubbleCollision(other, intersectionPoints); } return true; } void _handleBubbleCollision(Bubble other, Set intersectionPoints) { if (intersectionPoints.isEmpty) return; // Calculate collision direction final direction = (position - other.position).normalized(); // Apply collision response - bubbles bounce off each other final collisionForce = 30.0; velocity.add(direction * collisionForce); other.velocity.add(direction * -collisionForce); // Apply damping to prevent infinite acceleration velocity.scale(collisionDamping); other.velocity.scale(collisionDamping); // Add slight separation to prevent sticking final separation = direction * 2.0; position.add(separation); other.position.sub(separation); } /// Apply tilt force to bubble based on device orientation void applyTiltForce(double tiltStrength) { if (isPopping) return; // Apply force opposite to tilt direction // If device tilts right, bubbles move left and vice versa final tiltForce = -tiltStrength * 15.0; // Adjust force strength velocity.x += tiltForce; // Add slight vertical component for more natural movement velocity.y += tiltForce * 0.3; } }