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 = 40.0; // Small starting size for all bubbles static const double maxSize = 50.0; // Same final size for all bubbles static const double lifecycleDuration = 12.0; // Same lifecycle duration for all bubbles // Triple collision rule constants static const int maxCollisions = 3; static const double collisionGracePeriod = 2.0; // seconds late String bubbleImagePath; late Color bubbleColor; bool isPopping = false; bool poppedByUser = false; bool isAutoSpawned = true; // Whether bubble was auto-spawned or user-triggered Function(Bubble, bool)? onPop; // Collision tracking for triple collision rule int _collisionCount = 0; double _lastCollisionTime = 0.0; 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, this.isAutoSpawned = true}) : super( size: Vector2.all(startSize), position: position, anchor: Anchor.center, ) { // All bubbles have the same target size regardless of type _targetSize = 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 - will be updated dynamically add( CircleHitbox( radius: startSize / 2, // Start with initial size 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 scale based on lifecycle progress - grow from startSize to maxSize final currentScale = _lifecycleProgress * (_targetSize / startSize); scale = Vector2.all( 1.0 + currentScale * 3.0, ); // Scale from 1.0 to 4.0 over lifetime // Update collision hitbox radius to match current visual size // Use a slightly smaller radius for collision detection to prevent edge cases final currentRadius = ((startSize / 2) * scale.x) * 0.9; // 90% of visual size final hitbox = children.whereType().firstOrNull; if (hitbox != null) { hitbox.radius = currentRadius; } // 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(); // Update collision tracking _updateCollisionTracking(dt); // Periodic overlap check to prevent stuck overlaps _preventOverlapSticking(dt); // 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() { // Start at scale 1.0 (startSize) and grow gradually throughout lifecycle // The growth will be handled in the update method based on lifecycle progress // Simple fade-in animation only 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 = 12; // 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(); } }); } } // Track if shake effect is currently active to prevent overlapping effects bool _isShakeEffectActive = false; /// Add shake effect to this bubble (called when phone is shaken) /// Fixed version that only adds visual effects without movement or size flickering void addShakeEffect() { if (isPopping || _isShakeEffectActive) return; _isShakeEffectActive = true; final random = Random(); // Store current scale to return to it after effect final currentScale = scale.clone(); final targetScale = currentScale * 1.1; // Only 10% bigger than current size // Use absolute scaling to prevent flickering with lifecycle scaling final scaleUpEffect = ScaleEffect.to( targetScale, EffectController( duration: 0.1, // Very short duration curve: Curves.easeOut, ), ); final scaleDownEffect = ScaleEffect.to( currentScale, EffectController( duration: 0.1, // Very short duration curve: Curves.easeIn, ), ); // Add slight rotation effect - reduced intensity final rotateEffect = RotateEffect.by( (random.nextDouble() - 0.5) * 0.15, // Further reduced rotation EffectController( duration: 0.15, alternate: true, curve: Curves.easeInOut, ), ); // Chain the scale effects add(scaleUpEffect); scaleUpEffect.onComplete = () { if (isMounted && !isPopping) { add(scaleDownEffect); } }; add(rotateEffect); // Set completion callback to reset shake effect flag Future.delayed(const Duration(milliseconds: 200), () { _isShakeEffectActive = false; }); } // 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; // Track collision for triple collision rule _incrementCollisionCount(); other._incrementCollisionCount(); // Calculate collision direction final direction = (position - other.position); final distance = direction.length; // Prevent division by zero if (distance < 0.1) { // If bubbles are too close, separate them with a random direction final random = Random(); direction.setFrom( Vector2( (random.nextDouble() - 0.5) * 2, (random.nextDouble() - 0.5) * 2, ), ); } direction.normalize(); // Calculate required separation based on current bubble sizes final thisRadius = (startSize / 2) * scale.x; final otherRadius = (startSize / 2) * other.scale.x; final requiredDistance = thisRadius + otherRadius + 10.0; // Increased padding to 10px // If bubbles are overlapping, separate them immediately with more aggressive separation if (distance < requiredDistance) { final separationNeeded = requiredDistance - distance + 5.0; // Extra 5px buffer final separationVector = direction * (separationNeeded / 2); // Move both bubbles away from each other position.add(separationVector); other.position.sub(separationVector); // Ensure bubbles stay within bounds after separation _keepInBounds(); other._keepInBounds(); // Additional check: if still overlapping after separation, apply emergency separation final newDistance = (position - other.position).length; if (newDistance < requiredDistance) { final emergencyVector = direction * ((requiredDistance - newDistance + 10.0) / 2); position.add(emergencyVector); other.position.sub(emergencyVector); _keepInBounds(); other._keepInBounds(); } } // Apply collision response - bubbles bounce off each other final collisionForce = 25.0; // Reduced from 30.0 for gentler bouncing velocity.add(direction * collisionForce); other.velocity.add(direction * -collisionForce); // Apply stronger damping to prevent infinite acceleration velocity.scale(0.6); // Increased damping from 0.7 to 0.6 other.velocity.scale(0.6); } /// Update collision tracking and handle grace period reset void _updateCollisionTracking(double dt) { final currentTime = DateTime.now().millisecondsSinceEpoch / 1000.0; // Reset collision count if grace period has passed if (currentTime - _lastCollisionTime > collisionGracePeriod) { _collisionCount = 0; } } /// Increment collision count and check for auto-pop void _incrementCollisionCount() { if (isPopping) return; _collisionCount++; _lastCollisionTime = DateTime.now().millisecondsSinceEpoch / 1000.0; // Auto-pop after max collisions if (_collisionCount >= maxCollisions) { pop(userTriggered: false); // Auto-pop due to collision rule } } /// Get current collision count (for debugging/testing) int get collisionCount => _collisionCount; /// Apply tilt force to bubble based on device orientation void applyTiltForce(double tiltStrength) { if (isPopping) return; // Reduced tilt force to prevent excessive movement during shaking // Apply force opposite to tilt direction // If device tilts right, bubbles move left and vice versa final tiltForce = -tiltStrength * 8.0; // Reduced from 15.0 to 8.0 velocity.x += tiltForce; // Add slight vertical component for more natural movement velocity.y += tiltForce * 0.2; // Reduced from 0.3 to 0.2 } double _lastOverlapCheck = 0.0; static const double overlapCheckInterval = 0.1; // Check every 100ms /// Periodic check to prevent bubbles from getting stuck overlapping void _preventOverlapSticking(double dt) { _lastOverlapCheck += dt; // Only check periodically to avoid performance issues if (_lastOverlapCheck < overlapCheckInterval) return; _lastOverlapCheck = 0.0; // Get all other bubbles from the game final allBubbles = game.children.whereType(); for (final otherBubble in allBubbles) { if (otherBubble == this || otherBubble.isPopping || isPopping) continue; final distance = position.distanceTo(otherBubble.position); final thisRadius = (startSize / 2) * scale.x; final otherRadius = (startSize / 2) * otherBubble.scale.x; final requiredDistance = thisRadius + otherRadius + 8.0; // 8px padding // If bubbles are overlapping, apply gentle separation force if (distance < requiredDistance && distance > 0.1) { final direction = (position - otherBubble.position).normalized(); final separationForce = (requiredDistance - distance) * 0.5; // Gentle force // Apply separation force to velocities instead of direct position changes // to make the movement more natural velocity.add(direction * separationForce); otherBubble.velocity.add(direction * -separationForce); } } } }