import 'dart:math'; import 'package:flame/components.dart'; import 'bubble.dart'; class BubbleSpawner extends Component with HasGameReference { static const double baseSpawnInterval = 1.5; // base seconds static const double spawnVariation = 1.0; // +/- variation in seconds // Bubble rules constants static const double bubbleMaxDiameter = 50.0; static const double bubbleMinSpacing = 10.0; static const double screenMargin = 120.0; static const double uiTopMargin = 100.0; // Dynamic max bubbles based on screen size int _maxBubbles = 8; int get maxBubbles => _maxBubbles; double _timeSinceLastSpawn = 0; double _nextSpawnTime = 0; final List _activeBubbles = []; final Random _random = Random(); Function(Bubble, bool)? onBubblePopped; bool isActive = true; // Screen capacity calculation cache Vector2 _lastCalculatedScreenSize = Vector2.zero(); // Shake-enhanced spawning bool _isShakeMode = false; double _shakeModeEndTime = 0; static const double shakeModeSpeedMultiplier = 4.0; // 4x faster during shake static const double shakeModeDuration = 3.0; // seconds BubbleSpawner({this.onBubblePopped}) { _calculateNextSpawnTime(); } double _lastCleanupTime = 0; static const double cleanupInterval = 0.5; // Clean up bubbles every 0.5 seconds instead of every frame @override void update(double dt) { super.update(dt); if (!isActive) return; // Update max bubbles if screen size changed _updateMaxBubblesIfNeeded(); _timeSinceLastSpawn += dt; _lastCleanupTime += dt; // Check if shake mode should end if (_isShakeMode && _shakeModeEndTime <= DateTime.now().millisecondsSinceEpoch / 1000.0) { _isShakeMode = false; } // Spawn new bubble if conditions are met if (_timeSinceLastSpawn >= _nextSpawnTime && _activeBubbles.length < maxBubbles) { _spawnBubble(); _timeSinceLastSpawn = 0; _calculateNextSpawnTime(); } // Clean up popped bubbles less frequently to improve performance if (_lastCleanupTime >= cleanupInterval) { _activeBubbles.removeWhere((bubble) => !bubble.isMounted); _lastCleanupTime = 0; } } void _spawnBubble({bool isAutoSpawned = true}) { if (game.size.x == 0 || game.size.y == 0) return; final spawnPosition = _getValidSpawnPosition(); if (spawnPosition == null) return; final bubble = Bubble( position: spawnPosition, onPop: _onBubblePopped, isAutoSpawned: isAutoSpawned, ); // Double-check for overlaps before adding to prevent edge cases if (_validateBubblePosition(bubble)) { _activeBubbles.add(bubble); game.add(bubble); } } Vector2? _getValidSpawnPosition() { // Calculate safe spawn area (avoiding edges and UI elements) // Increased margin to account for larger bubble sprites const margin = 120.0; const maxAttempts = 20; // Increased attempts final minX = margin; final maxX = game.size.x - margin; final minY = margin + 100; // Extra margin for score display final maxY = game.size.y - margin; if (maxX <= minX || maxY <= minY) return null; // Try to find a position that doesn't overlap with existing bubbles for (int attempt = 0; attempt < maxAttempts; attempt++) { final position = Vector2( minX + _random.nextDouble() * (maxX - minX), minY + _random.nextDouble() * (maxY - minY), ); // Check if position is safe from all existing bubbles bool isSafe = true; for (final bubble in _activeBubbles) { if (!bubble.isPopping) { // Calculate required distance based on bubble sizes final bubbleRadius = (bubbleMaxDiameter / 2) * bubble.scale.x; final newBubbleRadius = bubbleMaxDiameter / 2; // New bubble starts small final requiredDistance = bubbleRadius + newBubbleRadius + 30.0; // Increased padding to 30px final distance = position.distanceTo(bubble.position); if (distance < requiredDistance) { isSafe = false; break; } } } if (isSafe) { return position; } } // If no safe position found after max attempts, return null return null; } void _onBubblePopped(Bubble bubble, bool userTriggered) { _activeBubbles.remove(bubble); onBubblePopped?.call(bubble, userTriggered); } void spawnBubbleAt(Vector2 position) { // Find a safe position near the requested position final safePosition = _findSafePositionNear(position); if (safePosition == null) return; // Don't spawn if no safe position found final bubble = Bubble( position: safePosition, onPop: _onBubblePopped, isAutoSpawned: false, // User-triggered bubbles ); // Double-check for overlaps before adding to prevent edge cases if (_validateBubblePosition(bubble)) { _activeBubbles.add(bubble); game.add(bubble); } } /// Find a safe position near the requested position that doesn't overlap with existing bubbles Vector2? _findSafePositionNear(Vector2 requestedPosition) { const maxAttempts = 15; // Increased attempts const searchRadius = 100.0; // How far to search around requested position // First, ensure the requested position is within bounds final clampedPosition = _clampPositionToBounds(requestedPosition); // Try the exact position first if (_isPositionSafeImproved(clampedPosition)) { return clampedPosition; } // If not safe, try positions in a circle around the requested position for (int attempt = 0; attempt < maxAttempts; attempt++) { final angle = _random.nextDouble() * 2 * pi; // Random angle final distance = _random.nextDouble() * searchRadius; // Random distance final testPosition = Vector2( clampedPosition.x + distance * cos(angle), clampedPosition.y + distance * sin(angle), ); final boundedPosition = _clampPositionToBounds(testPosition); if (_isPositionSafeImproved(boundedPosition)) { return boundedPosition; } } return null; // No safe position found } /// Check if a position is safe (far enough from all existing bubbles) /// This improved version accounts for bubble sizes and scaling bool _isPositionSafe(Vector2 position, [double? fixedMinDistance]) { for (final bubble in _activeBubbles) { if (!bubble.isPopping) { double requiredDistance; if (fixedMinDistance != null) { // Use fixed distance for backwards compatibility requiredDistance = fixedMinDistance; } else { // Calculate required distance based on bubble sizes final bubbleRadius = (bubbleMaxDiameter / 2) * bubble.scale.x; final newBubbleRadius = bubbleMaxDiameter / 2; // New bubble starts small requiredDistance = bubbleRadius + newBubbleRadius + 30.0; // Increased padding to 30px } final distance = position.distanceTo(bubble.position); if (distance < requiredDistance) { return false; } } } return true; } /// Improved position safety check that accounts for bubble sizes bool _isPositionSafeImproved(Vector2 position) { return _isPositionSafe(position); // Use the improved version by default } Vector2 _clampPositionToBounds(Vector2 position) { const margin = 120.0; final minX = margin; final maxX = game.size.x - margin; final minY = margin + 100; final maxY = game.size.y - margin; if (maxX <= minX || maxY <= minY) return position; return Vector2(position.x.clamp(minX, maxX), position.y.clamp(minY, maxY)); } void clearAllBubbles() { for (final bubble in _activeBubbles) { if (bubble.isMounted) { bubble.removeFromParent(); } } _activeBubbles.clear(); } void setActive(bool active) { isActive = active; } void _calculateNextSpawnTime() { // Base spawn time calculation double baseTime = baseSpawnInterval + (_random.nextDouble() - 0.5) * 2 * spawnVariation; // Apply shake mode speed multiplier if active if (_isShakeMode) { baseTime /= shakeModeSpeedMultiplier; } _nextSpawnTime = baseTime.clamp( 0.1, double.infinity, ); // Minimum 0.1 seconds for shake mode } int get activeBubbleCount => _activeBubbles.length; /// Get list of active bubbles for performance optimization List getActiveBubbles() => List.unmodifiable(_activeBubbles); /// Activate shake mode for faster bubble generation void activateShakeMode() { _isShakeMode = true; _shakeModeEndTime = (DateTime.now().millisecondsSinceEpoch / 1000.0) + shakeModeDuration; // Immediately recalculate spawn time for current bubble _calculateNextSpawnTime(); } /// Check if shake mode is currently active bool get isShakeModeActive => _isShakeMode; /// Update max bubbles calculation if screen size changed void _updateMaxBubblesIfNeeded() { if (game.size != _lastCalculatedScreenSize) { _maxBubbles = calculateMaxBubblesForScreen(); _lastCalculatedScreenSize = game.size.clone(); } } /// Calculate maximum number of full-size bubbles that can fit on screen /// based on bubble size, spacing, and screen dimensions int calculateMaxBubblesForScreen() { if (game.size.x == 0 || game.size.y == 0) { return 8; // fallback } return calculateMaxBubblesForScreenSize(game.size); } /// Static method to calculate max bubbles for given screen size /// This can be used for testing without requiring a game instance static int calculateMaxBubblesForScreenSize(Vector2 screenSize) { if (screenSize.x == 0 || screenSize.y == 0) { return 8; // fallback } // Calculate available screen area final availableWidth = screenSize.x - (2 * screenMargin); final availableHeight = screenSize.y - (2 * screenMargin) - uiTopMargin; if (availableWidth <= 0 || availableHeight <= 0) { return 4; // minimum fallback } // Calculate effective bubble area (including spacing) final effectiveBubbleSize = bubbleMaxDiameter + bubbleMinSpacing; final effectiveBubbleArea = effectiveBubbleSize * effectiveBubbleSize; // Calculate total available area final availableArea = availableWidth * availableHeight; // Calculate theoretical max bubbles final theoreticalMax = (availableArea / effectiveBubbleArea).floor(); // Apply practical limits for performance and gameplay final practicalMax = theoreticalMax.clamp(4, 15); // between 4-15 bubbles return practicalMax; } /// Get current screen capacity utilization as percentage double getScreenCapacityUtilization() { if (maxBubbles == 0) { return 0.0; } return (_activeBubbles.length / maxBubbles).clamp(0.0, 1.0); } /// Validate that a bubble position doesn't overlap with existing bubbles /// This is a final check before actually spawning the bubble bool _validateBubblePosition(Bubble newBubble) { const minSafeDistance = 40.0; // Minimum safe distance for new bubbles for (final existingBubble in _activeBubbles) { if (!existingBubble.isPopping) { final distance = newBubble.position.distanceTo(existingBubble.position); if (distance < minSafeDistance) { return false; // Position is not safe } } } return true; // Position is safe } }