bubble.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. import 'dart:math';
  2. import 'dart:async';
  3. import 'package:flame/components.dart';
  4. import 'package:flame/effects.dart';
  5. import 'package:flame/events.dart';
  6. import 'package:flame/collisions.dart';
  7. import 'package:flutter/material.dart';
  8. import '../../utils/colors.dart';
  9. class Bubble extends SpriteComponent
  10. with HasGameReference, TapCallbacks, CollisionCallbacks {
  11. static const double startSize = 40.0; // Small starting size for all bubbles
  12. static const double maxSize = 50.0; // Same final size for all bubbles
  13. static const double lifecycleDuration =
  14. 12.0; // Same lifecycle duration for all bubbles
  15. // Triple collision rule constants
  16. static const int maxCollisions = 3;
  17. static const double collisionGracePeriod = 2.0; // seconds
  18. late String bubbleImagePath;
  19. late Color bubbleColor;
  20. bool isPopping = false;
  21. bool poppedByUser = false;
  22. bool isAutoSpawned =
  23. true; // Whether bubble was auto-spawned or user-triggered
  24. Function(Bubble, bool)? onPop;
  25. // Collision tracking for triple collision rule
  26. int _collisionCount = 0;
  27. double _lastCollisionTime = 0.0;
  28. double _age = 0.0;
  29. double _lifecycleProgress = 0.0;
  30. late double _targetSize;
  31. // Physics properties for collision and movement
  32. Vector2 velocity = Vector2.zero();
  33. static const double maxSpeed = 50.0;
  34. static const double friction = 0.98;
  35. static const double collisionDamping = 0.7;
  36. double _floatTimer = 0.0;
  37. Bubble({required Vector2 position, this.onPop, this.isAutoSpawned = true})
  38. : super(
  39. size: Vector2.all(startSize),
  40. position: position,
  41. anchor: Anchor.center,
  42. ) {
  43. // All bubbles have the same target size regardless of type
  44. _targetSize = maxSize;
  45. }
  46. @override
  47. Future<void> onLoad() async {
  48. // Choose random bubble image and set corresponding color
  49. final bubbleData = _getRandomBubbleData();
  50. bubbleImagePath = bubbleData['path'];
  51. bubbleColor = bubbleData['color'];
  52. // Load the sprite
  53. sprite = await Sprite.load(bubbleImagePath);
  54. await super.onLoad();
  55. // Add circular collision hitbox - will be updated dynamically
  56. add(
  57. CircleHitbox(
  58. radius: startSize / 2, // Start with initial size
  59. anchor: Anchor.center,
  60. ),
  61. );
  62. // Start with zero scale for smooth entrance animation
  63. scale = Vector2.zero();
  64. // Add smooth entrance animation
  65. _addEntranceAnimation();
  66. // Add varied, organic floating animations
  67. _addRandomFloatingAnimation();
  68. _addRandomRotationAnimation();
  69. // Initialize random velocity for natural movement
  70. final random = Random();
  71. velocity = Vector2(
  72. (random.nextDouble() - 0.5) * 20, // -10 to +10
  73. (random.nextDouble() - 0.5) * 20, // -10 to +10
  74. );
  75. }
  76. @override
  77. void update(double dt) {
  78. super.update(dt);
  79. if (isPopping) return;
  80. // Update age and lifecycle
  81. _age += dt;
  82. _lifecycleProgress = (_age / lifecycleDuration).clamp(0.0, 1.0);
  83. // Update scale based on lifecycle progress - grow from startSize to maxSize
  84. final currentScale = _lifecycleProgress * (_targetSize / startSize);
  85. scale = Vector2.all(
  86. 1.0 + currentScale * 3.0,
  87. ); // Scale from 1.0 to 4.0 over lifetime
  88. // Update collision hitbox radius to match current visual size
  89. // Use a slightly smaller radius for collision detection to prevent edge cases
  90. final currentRadius =
  91. ((startSize / 2) * scale.x) * 0.9; // 90% of visual size
  92. final hitbox = children.whereType<CircleHitbox>().firstOrNull;
  93. if (hitbox != null) {
  94. hitbox.radius = currentRadius;
  95. }
  96. // Update opacity based on lifecycle (fade out towards the end)
  97. final opacityFactor =
  98. 1.0 - (_lifecycleProgress * 0.6); // Fade to 40% opacity
  99. opacity = opacityFactor.clamp(0.4, 1.0);
  100. // Physics-based movement
  101. _updatePhysics(dt);
  102. // Keep bubble within screen bounds
  103. _keepInBounds();
  104. // Update collision tracking
  105. _updateCollisionTracking(dt);
  106. // Periodic overlap check to prevent stuck overlaps
  107. _preventOverlapSticking(dt);
  108. // Auto-pop when lifecycle is complete
  109. if (_lifecycleProgress >= 1.0) {
  110. pop(userTriggered: false);
  111. }
  112. }
  113. void _updatePhysics(double dt) {
  114. // Apply velocity to position
  115. position.add(velocity * dt);
  116. // Apply friction
  117. velocity.scale(friction);
  118. // Add periodic floating effects
  119. _floatTimer += dt;
  120. if (_floatTimer > 2.0) {
  121. _floatTimer = 0.0;
  122. final random = Random();
  123. final floatForce = Vector2(
  124. (random.nextDouble() - 0.5) * 8, // -4 to +4 horizontal
  125. (random.nextDouble() - 0.5) * 8, // -4 to +4 vertical
  126. );
  127. velocity.add(floatForce);
  128. }
  129. // Clamp velocity to max speed
  130. if (velocity.length > maxSpeed) {
  131. velocity.normalize();
  132. velocity.scale(maxSpeed);
  133. }
  134. }
  135. void _keepInBounds() {
  136. const margin = 60.0;
  137. final gameSize = game.size;
  138. // Bounce off screen edges
  139. if (position.x < margin) {
  140. position.x = margin;
  141. velocity.x = velocity.x.abs(); // Bounce right
  142. } else if (position.x > gameSize.x - margin) {
  143. position.x = gameSize.x - margin;
  144. velocity.x = -velocity.x.abs(); // Bounce left
  145. }
  146. if (position.y < margin + 100) {
  147. // Account for UI at top
  148. position.y = margin + 100;
  149. velocity.y = velocity.y.abs(); // Bounce down
  150. } else if (position.y > gameSize.y - margin) {
  151. position.y = gameSize.y - margin;
  152. velocity.y = -velocity.y.abs(); // Bounce up
  153. }
  154. }
  155. Map<String, dynamic> _getRandomBubbleData() {
  156. final random = Random();
  157. final bubbleTypes = [
  158. {'path': 'bubble_blue.png', 'color': ZenColors.defaultLink},
  159. {'path': 'bubble_cyan.png', 'color': ZenColors.buttonBackground},
  160. {'path': 'bubble_green.png', 'color': const Color(0xFF00FF80)},
  161. {'path': 'bubble_purple.png', 'color': ZenColors.lightModeHover},
  162. ];
  163. return bubbleTypes[random.nextInt(bubbleTypes.length)];
  164. }
  165. void _addRandomFloatingAnimation() {
  166. final random = Random();
  167. // Add a small upward bias to the initial velocity
  168. velocity.y += -5 - random.nextDouble() * 10; // -5 to -15 upward bias
  169. // The floating effect will now be handled by the physics system
  170. // and periodic velocity changes in the update method
  171. }
  172. void _addEntranceAnimation() {
  173. // Start at scale 1.0 (startSize) and grow gradually throughout lifecycle
  174. // The growth will be handled in the update method based on lifecycle progress
  175. // Simple fade-in animation only
  176. opacity = 0.0;
  177. add(
  178. OpacityEffect.to(
  179. 1.0,
  180. EffectController(duration: 0.4, curve: Curves.easeOut),
  181. ),
  182. );
  183. }
  184. void _addRandomRotationAnimation() {
  185. final random = Random();
  186. // 30% chance for rotation animation
  187. if (random.nextDouble() < 0.3) {
  188. // Small random rotation between -0.2 and 0.2 radians
  189. final rotationAngle = (random.nextDouble() - 0.5) * 0.4;
  190. // Random rotation duration between 3 and 8 seconds
  191. final rotationDuration = 3.0 + random.nextDouble() * 5.0;
  192. // Random delay
  193. final delay = random.nextDouble() * 3.0;
  194. Future.delayed(Duration(milliseconds: (delay * 1000).round()), () {
  195. if (isMounted) {
  196. add(
  197. RotateEffect.by(
  198. rotationAngle,
  199. EffectController(
  200. duration: rotationDuration,
  201. alternate: true,
  202. infinite: true,
  203. curve: Curves.easeInOut,
  204. ),
  205. ),
  206. );
  207. }
  208. });
  209. }
  210. }
  211. @override
  212. bool onTapDown(TapDownEvent event) {
  213. if (!isPopping) {
  214. pop(userTriggered: true);
  215. }
  216. return true;
  217. }
  218. void pop({bool userTriggered = false}) {
  219. if (isPopping) return;
  220. isPopping = true;
  221. poppedByUser = userTriggered;
  222. // Create exciting multi-stage pop animation
  223. // Stage 1: Quick expand (0.1s)
  224. final expandEffect = ScaleEffect.to(
  225. Vector2.all(1.8),
  226. EffectController(duration: 0.1, curve: Curves.easeOut),
  227. );
  228. // Stage 2: Slight compression (0.05s)
  229. final compressEffect = ScaleEffect.to(
  230. Vector2.all(0.8),
  231. EffectController(duration: 0.05, curve: Curves.easeIn),
  232. );
  233. // Stage 3: Final explosion (0.15s)
  234. final explodeEffect = ScaleEffect.to(
  235. Vector2.all(2.5),
  236. EffectController(duration: 0.15, curve: Curves.easeOut),
  237. );
  238. // Fade out effect
  239. final fadeEffect = OpacityEffect.to(
  240. 0.0,
  241. EffectController(duration: 0.3, curve: Curves.easeIn),
  242. );
  243. // Rotation effect for more dynamic feel
  244. final rotateEffect = RotateEffect.by(
  245. 0.5, // Half rotation
  246. EffectController(duration: 0.3),
  247. );
  248. // Chain the scale effects
  249. add(expandEffect);
  250. expandEffect.onComplete = () {
  251. if (isMounted) {
  252. add(compressEffect);
  253. compressEffect.onComplete = () {
  254. if (isMounted) {
  255. add(explodeEffect);
  256. }
  257. };
  258. }
  259. };
  260. add(fadeEffect);
  261. add(rotateEffect);
  262. // Create enhanced particle effects
  263. _createExcitingPopParticles();
  264. // Notify parent and remove bubble
  265. onPop?.call(this, userTriggered);
  266. Future.delayed(const Duration(milliseconds: 300), () {
  267. if (isMounted) {
  268. removeFromParent();
  269. }
  270. });
  271. }
  272. void _createExcitingPopParticles() {
  273. final random = Random();
  274. const particleCount = 12; // Further reduced for better performance
  275. // Use object pooling approach - create fewer, simpler particles
  276. for (int i = 0; i < particleCount; i++) {
  277. final angle = (i / particleCount) * 2 * pi;
  278. // Simplified particle creation
  279. final baseSpeed = 50 + random.nextDouble() * 30; // 50-80 speed
  280. final particleVelocity = Vector2(
  281. cos(angle) * baseSpeed,
  282. sin(angle) * baseSpeed,
  283. );
  284. // Fixed particle size for performance
  285. const particleSize = 3.0;
  286. // Simplified particle color
  287. final particleColor = bubbleColor.withValues(alpha: 0.8);
  288. final particle = CircleComponent(
  289. radius: particleSize,
  290. position: position.clone(),
  291. paint:
  292. Paint()
  293. ..color = particleColor
  294. ..style = PaintingStyle.fill,
  295. );
  296. parent?.add(particle);
  297. // Combine effects for better performance
  298. final combinedEffect = MoveEffect.by(
  299. particleVelocity + Vector2(0, 40), // Combined movement + gravity
  300. EffectController(
  301. duration: 0.3,
  302. curve: Curves.easeOut,
  303. ), // Faster animation
  304. );
  305. particle.add(combinedEffect);
  306. // Single fade effect instead of multiple effects
  307. particle.add(
  308. OpacityEffect.to(
  309. 0.0,
  310. EffectController(duration: 0.3, curve: Curves.easeIn),
  311. ),
  312. );
  313. // Faster cleanup
  314. Future.delayed(const Duration(milliseconds: 300), () {
  315. if (particle.isMounted) {
  316. particle.removeFromParent();
  317. }
  318. });
  319. }
  320. }
  321. // Track if shake effect is currently active to prevent overlapping effects
  322. bool _isShakeEffectActive = false;
  323. /// Add shake effect to this bubble (called when phone is shaken)
  324. /// Fixed version that only adds visual effects without movement or size flickering
  325. void addShakeEffect() {
  326. if (isPopping || _isShakeEffectActive) return;
  327. _isShakeEffectActive = true;
  328. final random = Random();
  329. // Store current scale to return to it after effect
  330. final currentScale = scale.clone();
  331. final targetScale = currentScale * 1.1; // Only 10% bigger than current size
  332. // Use absolute scaling to prevent flickering with lifecycle scaling
  333. final scaleUpEffect = ScaleEffect.to(
  334. targetScale,
  335. EffectController(
  336. duration: 0.1, // Very short duration
  337. curve: Curves.easeOut,
  338. ),
  339. );
  340. final scaleDownEffect = ScaleEffect.to(
  341. currentScale,
  342. EffectController(
  343. duration: 0.1, // Very short duration
  344. curve: Curves.easeIn,
  345. ),
  346. );
  347. // Add slight rotation effect - reduced intensity
  348. final rotateEffect = RotateEffect.by(
  349. (random.nextDouble() - 0.5) * 0.15, // Further reduced rotation
  350. EffectController(
  351. duration: 0.15,
  352. alternate: true,
  353. curve: Curves.easeInOut,
  354. ),
  355. );
  356. // Chain the scale effects
  357. add(scaleUpEffect);
  358. scaleUpEffect.onComplete = () {
  359. if (isMounted && !isPopping) {
  360. add(scaleDownEffect);
  361. }
  362. };
  363. add(rotateEffect);
  364. // Set completion callback to reset shake effect flag
  365. Future.delayed(const Duration(milliseconds: 200), () {
  366. _isShakeEffectActive = false;
  367. });
  368. }
  369. // Collision detection methods
  370. @override
  371. bool onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
  372. super.onCollision(intersectionPoints, other);
  373. if (other is Bubble && !isPopping && !other.isPopping) {
  374. _handleBubbleCollision(other, intersectionPoints);
  375. }
  376. return true;
  377. }
  378. void _handleBubbleCollision(Bubble other, Set<Vector2> intersectionPoints) {
  379. if (intersectionPoints.isEmpty) return;
  380. // Track collision for triple collision rule
  381. _incrementCollisionCount();
  382. other._incrementCollisionCount();
  383. // Calculate collision direction
  384. final direction = (position - other.position);
  385. final distance = direction.length;
  386. // Prevent division by zero
  387. if (distance < 0.1) {
  388. // If bubbles are too close, separate them with a random direction
  389. final random = Random();
  390. direction.setFrom(
  391. Vector2(
  392. (random.nextDouble() - 0.5) * 2,
  393. (random.nextDouble() - 0.5) * 2,
  394. ),
  395. );
  396. }
  397. direction.normalize();
  398. // Calculate required separation based on current bubble sizes
  399. final thisRadius = (startSize / 2) * scale.x;
  400. final otherRadius = (startSize / 2) * other.scale.x;
  401. final requiredDistance =
  402. thisRadius + otherRadius + 10.0; // Increased padding to 10px
  403. // If bubbles are overlapping, separate them immediately with more aggressive separation
  404. if (distance < requiredDistance) {
  405. final separationNeeded =
  406. requiredDistance - distance + 5.0; // Extra 5px buffer
  407. final separationVector = direction * (separationNeeded / 2);
  408. // Move both bubbles away from each other
  409. position.add(separationVector);
  410. other.position.sub(separationVector);
  411. // Ensure bubbles stay within bounds after separation
  412. _keepInBounds();
  413. other._keepInBounds();
  414. // Additional check: if still overlapping after separation, apply emergency separation
  415. final newDistance = (position - other.position).length;
  416. if (newDistance < requiredDistance) {
  417. final emergencyVector =
  418. direction * ((requiredDistance - newDistance + 10.0) / 2);
  419. position.add(emergencyVector);
  420. other.position.sub(emergencyVector);
  421. _keepInBounds();
  422. other._keepInBounds();
  423. }
  424. }
  425. // Apply collision response - bubbles bounce off each other
  426. final collisionForce = 25.0; // Reduced from 30.0 for gentler bouncing
  427. velocity.add(direction * collisionForce);
  428. other.velocity.add(direction * -collisionForce);
  429. // Apply stronger damping to prevent infinite acceleration
  430. velocity.scale(0.6); // Increased damping from 0.7 to 0.6
  431. other.velocity.scale(0.6);
  432. }
  433. /// Update collision tracking and handle grace period reset
  434. void _updateCollisionTracking(double dt) {
  435. final currentTime = DateTime.now().millisecondsSinceEpoch / 1000.0;
  436. // Reset collision count if grace period has passed
  437. if (currentTime - _lastCollisionTime > collisionGracePeriod) {
  438. _collisionCount = 0;
  439. }
  440. }
  441. /// Increment collision count and check for auto-pop
  442. void _incrementCollisionCount() {
  443. if (isPopping) return;
  444. _collisionCount++;
  445. _lastCollisionTime = DateTime.now().millisecondsSinceEpoch / 1000.0;
  446. // Auto-pop after max collisions
  447. if (_collisionCount >= maxCollisions) {
  448. pop(userTriggered: false); // Auto-pop due to collision rule
  449. }
  450. }
  451. /// Get current collision count (for debugging/testing)
  452. int get collisionCount => _collisionCount;
  453. /// Apply tilt force to bubble based on device orientation
  454. void applyTiltForce(double tiltStrength) {
  455. if (isPopping) return;
  456. // Reduced tilt force to prevent excessive movement during shaking
  457. // Apply force opposite to tilt direction
  458. // If device tilts right, bubbles move left and vice versa
  459. final tiltForce = -tiltStrength * 8.0; // Reduced from 15.0 to 8.0
  460. velocity.x += tiltForce;
  461. // Add slight vertical component for more natural movement
  462. velocity.y += tiltForce * 0.2; // Reduced from 0.3 to 0.2
  463. }
  464. double _lastOverlapCheck = 0.0;
  465. static const double overlapCheckInterval = 0.1; // Check every 100ms
  466. /// Periodic check to prevent bubbles from getting stuck overlapping
  467. void _preventOverlapSticking(double dt) {
  468. _lastOverlapCheck += dt;
  469. // Only check periodically to avoid performance issues
  470. if (_lastOverlapCheck < overlapCheckInterval) return;
  471. _lastOverlapCheck = 0.0;
  472. // Get all other bubbles from the game
  473. final allBubbles = game.children.whereType<Bubble>();
  474. for (final otherBubble in allBubbles) {
  475. if (otherBubble == this || otherBubble.isPopping || isPopping) continue;
  476. final distance = position.distanceTo(otherBubble.position);
  477. final thisRadius = (startSize / 2) * scale.x;
  478. final otherRadius = (startSize / 2) * otherBubble.scale.x;
  479. final requiredDistance = thisRadius + otherRadius + 8.0; // 8px padding
  480. // If bubbles are overlapping, apply gentle separation force
  481. if (distance < requiredDistance && distance > 0.1) {
  482. final direction = (position - otherBubble.position).normalized();
  483. final separationForce =
  484. (requiredDistance - distance) * 0.5; // Gentle force
  485. // Apply separation force to velocities instead of direct position changes
  486. // to make the movement more natural
  487. velocity.add(direction * separationForce);
  488. otherBubble.velocity.add(direction * -separationForce);
  489. }
  490. }
  491. }
  492. }