bubble.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  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 with HasGameReference, TapCallbacks, CollisionCallbacks {
  10. static const double startSize = 60.0; // Increased from 40.0
  11. static const double maxSize = 120.0; // Increased from 100.0
  12. static const double lifecycleDuration = 12.0; // seconds
  13. late String bubbleImagePath;
  14. late Color bubbleColor;
  15. bool isPopping = false;
  16. bool poppedByUser = false;
  17. Function(Bubble, bool)? onPop;
  18. double _age = 0.0;
  19. double _lifecycleProgress = 0.0;
  20. late double _targetSize;
  21. // Physics properties for collision and movement
  22. Vector2 velocity = Vector2.zero();
  23. static const double maxSpeed = 50.0;
  24. static const double friction = 0.98;
  25. static const double collisionDamping = 0.7;
  26. double _floatTimer = 0.0;
  27. Bubble({
  28. required Vector2 position,
  29. this.onPop,
  30. }) : super(
  31. size: Vector2.all(startSize),
  32. position: position,
  33. anchor: Anchor.center,
  34. ) {
  35. final random = Random();
  36. _targetSize = (maxSize * 0.6) + (random.nextDouble() * maxSize * 0.4); // 60-100% of maxSize
  37. }
  38. @override
  39. Future<void> onLoad() async {
  40. // Choose random bubble image and set corresponding color
  41. final bubbleData = _getRandomBubbleData();
  42. bubbleImagePath = bubbleData['path'];
  43. bubbleColor = bubbleData['color'];
  44. // Load the sprite
  45. sprite = await Sprite.load(bubbleImagePath);
  46. await super.onLoad();
  47. // Add circular collision hitbox
  48. add(CircleHitbox(
  49. radius: _targetSize / 2,
  50. anchor: Anchor.center,
  51. ));
  52. // Start with zero scale for smooth entrance animation
  53. scale = Vector2.zero();
  54. // Add smooth entrance animation
  55. _addEntranceAnimation();
  56. // Add varied, organic floating animations
  57. _addRandomFloatingAnimation();
  58. _addRandomRotationAnimation();
  59. // Initialize random velocity for natural movement
  60. final random = Random();
  61. velocity = Vector2(
  62. (random.nextDouble() - 0.5) * 20, // -10 to +10
  63. (random.nextDouble() - 0.5) * 20, // -10 to +10
  64. );
  65. }
  66. @override
  67. void update(double dt) {
  68. super.update(dt);
  69. if (isPopping) return;
  70. // Update age and lifecycle
  71. _age += dt;
  72. _lifecycleProgress = (_age / lifecycleDuration).clamp(0.0, 1.0);
  73. // Update size based on lifecycle (grow from startSize to targetSize)
  74. final currentSize = startSize + (_targetSize - startSize) * _lifecycleProgress;
  75. size = Vector2.all(currentSize);
  76. // Update opacity based on lifecycle (fade out towards the end)
  77. final opacityFactor = 1.0 - (_lifecycleProgress * 0.6); // Fade to 40% opacity
  78. opacity = opacityFactor.clamp(0.4, 1.0);
  79. // Physics-based movement
  80. _updatePhysics(dt);
  81. // Keep bubble within screen bounds
  82. _keepInBounds();
  83. // Auto-pop when lifecycle is complete
  84. if (_lifecycleProgress >= 1.0) {
  85. pop(userTriggered: false);
  86. }
  87. }
  88. void _updatePhysics(double dt) {
  89. // Apply velocity to position
  90. position.add(velocity * dt);
  91. // Apply friction
  92. velocity.scale(friction);
  93. // Add periodic floating effects
  94. _floatTimer += dt;
  95. if (_floatTimer > 2.0) {
  96. _floatTimer = 0.0;
  97. final random = Random();
  98. final floatForce = Vector2(
  99. (random.nextDouble() - 0.5) * 8, // -4 to +4 horizontal
  100. (random.nextDouble() - 0.5) * 8, // -4 to +4 vertical
  101. );
  102. velocity.add(floatForce);
  103. }
  104. // Clamp velocity to max speed
  105. if (velocity.length > maxSpeed) {
  106. velocity.normalize();
  107. velocity.scale(maxSpeed);
  108. }
  109. }
  110. void _keepInBounds() {
  111. const margin = 60.0;
  112. final gameSize = game.size;
  113. // Bounce off screen edges
  114. if (position.x < margin) {
  115. position.x = margin;
  116. velocity.x = velocity.x.abs(); // Bounce right
  117. } else if (position.x > gameSize.x - margin) {
  118. position.x = gameSize.x - margin;
  119. velocity.x = -velocity.x.abs(); // Bounce left
  120. }
  121. if (position.y < margin + 100) { // Account for UI at top
  122. position.y = margin + 100;
  123. velocity.y = velocity.y.abs(); // Bounce down
  124. } else if (position.y > gameSize.y - margin) {
  125. position.y = gameSize.y - margin;
  126. velocity.y = -velocity.y.abs(); // Bounce up
  127. }
  128. }
  129. Map<String, dynamic> _getRandomBubbleData() {
  130. final random = Random();
  131. final bubbleTypes = [
  132. {
  133. 'path': 'bubble_blue.png',
  134. 'color': ZenColors.defaultLink,
  135. },
  136. {
  137. 'path': 'bubble_cyan.png',
  138. 'color': ZenColors.buttonBackground,
  139. },
  140. {
  141. 'path': 'bubble_green.png',
  142. 'color': const Color(0xFF00FF80),
  143. },
  144. {
  145. 'path': 'bubble_purple.png',
  146. 'color': ZenColors.lightModeHover,
  147. },
  148. ];
  149. return bubbleTypes[random.nextInt(bubbleTypes.length)];
  150. }
  151. void _addRandomFloatingAnimation() {
  152. final random = Random();
  153. // Add a small upward bias to the initial velocity
  154. velocity.y += -5 - random.nextDouble() * 10; // -5 to -15 upward bias
  155. // The floating effect will now be handled by the physics system
  156. // and periodic velocity changes in the update method
  157. }
  158. void _addEntranceAnimation() {
  159. // Smooth scale-in animation with bounce effect
  160. add(
  161. ScaleEffect.to(
  162. Vector2.all(1.0),
  163. EffectController(
  164. duration: 0.6,
  165. curve: Curves.elasticOut,
  166. ),
  167. ),
  168. );
  169. // Optional fade-in animation
  170. opacity = 0.0;
  171. add(
  172. OpacityEffect.to(
  173. 1.0,
  174. EffectController(
  175. duration: 0.4,
  176. curve: Curves.easeOut,
  177. ),
  178. ),
  179. );
  180. }
  181. void _addRandomRotationAnimation() {
  182. final random = Random();
  183. // 30% chance for rotation animation
  184. if (random.nextDouble() < 0.3) {
  185. // Small random rotation between -0.2 and 0.2 radians
  186. final rotationAngle = (random.nextDouble() - 0.5) * 0.4;
  187. // Random rotation duration between 3 and 8 seconds
  188. final rotationDuration = 3.0 + random.nextDouble() * 5.0;
  189. // Random delay
  190. final delay = random.nextDouble() * 3.0;
  191. Future.delayed(Duration(milliseconds: (delay * 1000).round()), () {
  192. if (isMounted) {
  193. add(
  194. RotateEffect.by(
  195. rotationAngle,
  196. EffectController(
  197. duration: rotationDuration,
  198. alternate: true,
  199. infinite: true,
  200. curve: Curves.easeInOut,
  201. ),
  202. ),
  203. );
  204. }
  205. });
  206. }
  207. }
  208. @override
  209. bool onTapDown(TapDownEvent event) {
  210. if (!isPopping) {
  211. pop(userTriggered: true);
  212. }
  213. return true;
  214. }
  215. void pop({bool userTriggered = false}) {
  216. if (isPopping) return;
  217. isPopping = true;
  218. poppedByUser = userTriggered;
  219. // Create exciting multi-stage pop animation
  220. // Stage 1: Quick expand (0.1s)
  221. final expandEffect = ScaleEffect.to(
  222. Vector2.all(1.8),
  223. EffectController(duration: 0.1, curve: Curves.easeOut),
  224. );
  225. // Stage 2: Slight compression (0.05s)
  226. final compressEffect = ScaleEffect.to(
  227. Vector2.all(0.8),
  228. EffectController(duration: 0.05, curve: Curves.easeIn),
  229. );
  230. // Stage 3: Final explosion (0.15s)
  231. final explodeEffect = ScaleEffect.to(
  232. Vector2.all(2.5),
  233. EffectController(duration: 0.15, curve: Curves.easeOut),
  234. );
  235. // Fade out effect
  236. final fadeEffect = OpacityEffect.to(
  237. 0.0,
  238. EffectController(duration: 0.3, curve: Curves.easeIn),
  239. );
  240. // Rotation effect for more dynamic feel
  241. final rotateEffect = RotateEffect.by(
  242. 0.5, // Half rotation
  243. EffectController(duration: 0.3),
  244. );
  245. // Chain the scale effects
  246. add(expandEffect);
  247. expandEffect.onComplete = () {
  248. if (isMounted) {
  249. add(compressEffect);
  250. compressEffect.onComplete = () {
  251. if (isMounted) {
  252. add(explodeEffect);
  253. }
  254. };
  255. }
  256. };
  257. add(fadeEffect);
  258. add(rotateEffect);
  259. // Create enhanced particle effects
  260. _createExcitingPopParticles();
  261. // Notify parent and remove bubble
  262. onPop?.call(this, userTriggered);
  263. Future.delayed(const Duration(milliseconds: 300), () {
  264. if (isMounted) {
  265. removeFromParent();
  266. }
  267. });
  268. }
  269. void _createExcitingPopParticles() {
  270. final random = Random();
  271. const particleCount = 8; // Reduced from 16 for better performance
  272. for (int i = 0; i < particleCount; i++) {
  273. final angle = (i / particleCount) * 2 * pi;
  274. // Varied particle speeds for more dynamic effect
  275. final baseSpeed = 60 + random.nextDouble() * 40; // 60-100 speed
  276. final particleVelocity = Vector2(
  277. cos(angle) * baseSpeed,
  278. sin(angle) * baseSpeed,
  279. );
  280. // Different particle sizes
  281. final particleSize = 2 + random.nextDouble() * 5; // 2-7 radius
  282. // Create particle with random color variation
  283. final particleColor = bubbleColor.withValues(
  284. alpha: 0.7 + random.nextDouble() * 0.3, // 70-100% alpha
  285. );
  286. final particle = CircleComponent(
  287. radius: particleSize,
  288. position: position.clone(),
  289. paint: Paint()
  290. ..color = particleColor
  291. ..style = PaintingStyle.fill,
  292. );
  293. parent?.add(particle);
  294. // Add movement effect with gravity
  295. particle.add(
  296. MoveEffect.by(
  297. particleVelocity,
  298. EffectController(duration: 0.5, curve: Curves.easeOut), // Reduced from 0.8
  299. ),
  300. );
  301. // Add simplified gravity effect
  302. final gravityEffect = MoveEffect.by(
  303. Vector2(0, 60), // Reduced gravity for shorter duration
  304. EffectController(duration: 0.5, curve: Curves.easeIn), // Reduced from 0.8
  305. );
  306. particle.add(gravityEffect);
  307. // Add scale effect (shrink over time)
  308. particle.add(
  309. ScaleEffect.to(
  310. Vector2.all(0.1),
  311. EffectController(duration: 0.5, curve: Curves.easeIn), // Reduced from 0.8
  312. ),
  313. );
  314. // Add fade effect
  315. particle.add(
  316. OpacityEffect.to(
  317. 0.0,
  318. EffectController(duration: 0.5, curve: Curves.easeIn), // Reduced from 0.8
  319. ),
  320. );
  321. // Remove particle after animation (reduced cleanup time)
  322. Future.delayed(const Duration(milliseconds: 500), () {
  323. if (particle.isMounted) {
  324. particle.removeFromParent();
  325. }
  326. });
  327. }
  328. }
  329. /// Add shake effect to this bubble (called when phone is shaken)
  330. void addShakeEffect() {
  331. if (isPopping) return;
  332. final random = Random();
  333. // Random shake direction
  334. final shakeDirection = Vector2(
  335. (random.nextDouble() - 0.5) * 60, // -30 to +30
  336. (random.nextDouble() - 0.5) * 60, // -30 to +30
  337. );
  338. // Add shake movement effect
  339. add(
  340. MoveEffect.by(
  341. shakeDirection,
  342. EffectController(
  343. duration: 0.4,
  344. alternate: true,
  345. curve: Curves.elasticOut,
  346. ),
  347. ),
  348. );
  349. // Add scale bounce effect
  350. add(
  351. ScaleEffect.by(
  352. Vector2.all(0.3),
  353. EffectController(
  354. duration: 0.2,
  355. alternate: true,
  356. curve: Curves.elasticOut,
  357. ),
  358. ),
  359. );
  360. // Add slight rotation effect
  361. add(
  362. RotateEffect.by(
  363. (random.nextDouble() - 0.5) * 0.4, // Random rotation
  364. EffectController(
  365. duration: 0.3,
  366. alternate: true,
  367. curve: Curves.elasticOut,
  368. ),
  369. ),
  370. );
  371. }
  372. // Collision detection methods
  373. @override
  374. bool onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
  375. super.onCollision(intersectionPoints, other);
  376. if (other is Bubble && !isPopping && !other.isPopping) {
  377. _handleBubbleCollision(other, intersectionPoints);
  378. }
  379. return true;
  380. }
  381. void _handleBubbleCollision(Bubble other, Set<Vector2> intersectionPoints) {
  382. if (intersectionPoints.isEmpty) return;
  383. // Calculate collision direction
  384. final direction = (position - other.position).normalized();
  385. // Apply collision response - bubbles bounce off each other
  386. final collisionForce = 30.0;
  387. velocity.add(direction * collisionForce);
  388. other.velocity.add(direction * -collisionForce);
  389. // Apply damping to prevent infinite acceleration
  390. velocity.scale(collisionDamping);
  391. other.velocity.scale(collisionDamping);
  392. // Add slight separation to prevent sticking
  393. final separation = direction * 2.0;
  394. position.add(separation);
  395. other.position.sub(separation);
  396. }
  397. /// Apply tilt force to bubble based on device orientation
  398. void applyTiltForce(double tiltStrength) {
  399. if (isPopping) return;
  400. // Apply force opposite to tilt direction
  401. // If device tilts right, bubbles move left and vice versa
  402. final tiltForce = -tiltStrength * 15.0; // Adjust force strength
  403. velocity.x += tiltForce;
  404. // Add slight vertical component for more natural movement
  405. velocity.y += tiltForce * 0.3;
  406. }
  407. }