| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601 |
- 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<void> 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<CircleHitbox>().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<String, dynamic> _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<Vector2> intersectionPoints, PositionComponent other) {
- super.onCollision(intersectionPoints, other);
- if (other is Bubble && !isPopping && !other.isPopping) {
- _handleBubbleCollision(other, intersectionPoints);
- }
- return true;
- }
- void _handleBubbleCollision(Bubble other, Set<Vector2> 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<Bubble>();
- 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);
- }
- }
- }
- }
|