import 'package:flame/components.dart'; import 'package:flame/effects.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'dart:math'; import '../utils/colors.dart'; import '../utils/score_manager.dart'; import '../utils/settings_manager.dart'; import '../utils/tilt_detector.dart'; import '../utils/google_play_games_manager.dart'; import 'components/bubble.dart'; import 'components/bubble_spawner.dart'; import 'audio/audio_manager.dart'; class ZenTapGame extends FlameGame with HasCollisionDetection { static const String routeName = '/game'; TextComponent? scoreText; TextComponent? timerText; int score = 0; bool isZenMode = false; double gameTime = 0.0; bool gameActive = true; // Localized strings String zenModeText = 'Zen Mode'; String relaxationPointsText = 'Relaxation Points'; String timeText = 'Time'; BubbleSpawner? bubbleSpawner; final AudioManager audioManager = AudioManager(); final TiltDetector _tiltDetector = TiltDetector(); final TiltDetector tiltDetector = TiltDetector(); final GooglePlayGamesManager _gamesManager = GooglePlayGamesManager.instance; // Google Play Games tracking int _totalBubblesPopped = 0; double _sessionStartTime = 0.0; bool _perfectSession = true; // No missed bubbles // Performance optimization: cache last displayed second to avoid unnecessary text updates int _lastDisplayedSecond = -1; /// Set localized strings for the game void setLocalizedStrings({ required String zenMode, required String relaxationPoints, required String time, }) { zenModeText = zenMode; relaxationPointsText = relaxationPoints; timeText = time; } @override Color backgroundColor() => Colors.transparent; @override Future onLoad() async { await super.onLoad(); // Initialize Google Play Games await _gamesManager.initialize(); // Track session start time _sessionStartTime = DateTime.now().millisecondsSinceEpoch / 1000.0; // Initialize score manager and audio await ScoreManager.initialize(); await audioManager.initialize(); // Apply saved audio settings after initialization audioManager.setMusicEnabled(SettingsManager.isMusicEnabled); audioManager.setHapticEnabled(SettingsManager.isHapticsEnabled); audioManager.setBgmVolume(SettingsManager.bgmVolume); audioManager.setSfxVolume(SettingsManager.sfxVolume); // Initialize score display scoreText = TextComponent( text: isZenMode ? zenModeText : '$relaxationPointsText: $score', textRenderer: TextPaint( style: const TextStyle( color: ZenColors.scoreText, fontSize: 24, fontWeight: FontWeight.w500, ), ), position: Vector2(20, 50), ); add(scoreText!); // Initialize timer display (only in regular mode) if (!isZenMode) { timerText = TextComponent( text: '$timeText: ${_formatTime(gameTime)}', textRenderer: TextPaint( style: const TextStyle( color: ZenColors.timerText, fontSize: 18, ), ), position: Vector2(20, 80), ); add(timerText!); } // Add initial game elements await _initializeGame(); // Start background music after all initialization is complete await audioManager.playBackgroundMusic(); // Start tilt detection _initializeTiltDetection(); } double _lastTiltUpdateTime = 0.0; static const double tiltUpdateInterval = 0.1; // Update tilt effects max 10 times per second void _initializeTiltDetection() { _tiltDetector.startListening( onTiltChanged: (tiltAngle) { // Throttle tilt updates to improve performance final currentTime = DateTime.now().millisecondsSinceEpoch / 1000.0; if (currentTime - _lastTiltUpdateTime >= tiltUpdateInterval) { _lastTiltUpdateTime = currentTime; _applyTiltToAllBubbles(_tiltDetector.normalizedTilt); } }, onShakeDetected: () { handleShake(); }, ); } void _applyTiltToAllBubbles(double tiltStrength) async { if (bubbleSpawner == null) return; // Apply tilt forces asynchronously to avoid blocking main thread final activeBubbles = bubbleSpawner!.getActiveBubbles(); // Process bubbles in smaller batches to prevent frame drops const batchSize = 3; for (int i = 0; i < activeBubbles.length; i += batchSize) { final endIndex = (i + batchSize).clamp(0, activeBubbles.length); final batch = activeBubbles.sublist(i, endIndex); for (final bubble in batch) { bubble.applyTiltForce(tiltStrength); } // Yield control back to the framework between batches if (endIndex < activeBubbles.length) { await Future.delayed(Duration.zero); } } } Future _initializeGame() async { // Initialize bubble spawner bubbleSpawner = BubbleSpawner( onBubblePopped: _onBubblePopped, ); add(bubbleSpawner!); // Instruction text removed for cleaner interface // Background music is now started in onLoad() after all initialization } @override void update(double dt) { super.update(dt); if (gameActive && !isZenMode) { gameTime += dt; // Performance optimization: only update timer text when the second changes final currentSecond = gameTime.toInt(); if (currentSecond != _lastDisplayedSecond) { _lastDisplayedSecond = currentSecond; _updateTimer(); } } } @override void onGameResize(Vector2 size) { super.onGameResize(size); // Update text positions when screen size changes if (scoreText != null) { scoreText!.position = Vector2(20, 50); } if (timerText != null) { timerText!.position = Vector2(20, 80); } // Reposition bubbles that might be off-screen _repositionBubblesInBounds(); } void _repositionBubblesInBounds() { if (bubbleSpawner == null) return; const margin = 120.0; final minX = margin; final maxX = size.x - margin; final minY = margin + 100; final maxY = size.y - margin; if (maxX <= minX || maxY <= minY) return; // Performance optimization: use bubble spawner's cached active bubbles final activeBubbles = bubbleSpawner!.getActiveBubbles(); for (final bubble in activeBubbles) { if (!bubble.isPopping) { bool needsRepositioning = false; Vector2 newPosition = bubble.position.clone(); if (bubble.position.x < minX) { newPosition.x = minX; needsRepositioning = true; } else if (bubble.position.x > maxX) { newPosition.x = maxX; needsRepositioning = true; } if (bubble.position.y < minY) { newPosition.y = minY; needsRepositioning = true; } else if (bubble.position.y > maxY) { newPosition.y = maxY; needsRepositioning = true; } if (needsRepositioning) { bubble.position = newPosition; } } } } void handleTap(Vector2 position) { if (!gameActive) return; // Create a bubble at tap position if in zen mode or no bubble was hit if (isZenMode) { bubbleSpawner?.spawnBubbleAt(position); } // Add visual feedback at tap position _addTapEffect(position); } void _onBubblePopped(Bubble bubble, bool userTriggered) { // Play pop sound and haptic feedback audioManager.playBubblePop(); // Track Google Play Games statistics if (userTriggered) { _totalBubblesPopped++; // Check for perfect session achievement after significant bubbles if (_totalBubblesPopped >= 50 && _perfectSession) { _gamesManager.unlockAchievement( GooglePlayGamesManager.achievementPerfectSession ); } } else { // User missed a bubble (it timed out) _perfectSession = false; } if (!isZenMode && userTriggered) { // Only increment score for user-triggered pops in regular mode score += 10; // 10 points per bubble ScoreManager.addScore(10); _updateScore(); // Check for speed achievements _gamesManager.checkScoreAchievements(score, gameTime); } // Check bubble-based achievements _gamesManager.checkBubbleAchievements(_totalBubblesPopped); } void _updateScore() { scoreText?.text = '$relaxationPointsText: $score'; } void _updateTimer() { timerText?.text = '$timeText: ${_formatTime(gameTime)}'; } // Helper method to format time as MM:SS String _formatTime(double seconds) { final minutes = (seconds / 60).floor(); final remainingSeconds = (seconds % 60).floor(); return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; } void _addTapEffect(Vector2 position) { // Create a simple tap effect circle with animation final tapEffect = CircleComponent( radius: 15, paint: Paint() ..color = ZenColors.bubblePopEffect.withValues(alpha: 0.6) ..style = PaintingStyle.stroke ..strokeWidth = 2, position: position, anchor: Anchor.center, ); add(tapEffect); // Add scaling animation tapEffect.add( ScaleEffect.to( Vector2.all(2.0), EffectController(duration: 0.3), ), ); // Add fade animation tapEffect.add( OpacityEffect.to( 0.0, EffectController(duration: 0.3), ), ); // Remove the effect after animation Future.delayed(const Duration(milliseconds: 300), () { if (tapEffect.isMounted) { remove(tapEffect); } }); } void setZenMode(bool zenMode) { isZenMode = zenMode; if (scoreText != null) { if (zenMode) { scoreText!.text = zenModeText; score = 0; // Hide timer in zen mode timerText?.removeFromParent(); timerText = null; } else { scoreText!.text = '$relaxationPointsText: $score'; // Show timer in regular mode if (timerText == null) { timerText = TextComponent( text: '$timeText: ${_formatTime(gameTime)}', textRenderer: TextPaint( style: const TextStyle( color: ZenColors.timerText, fontSize: 18, ), ), position: Vector2(20, 80), ); add(timerText!); } } } // Update bubble spawner behavior bubbleSpawner?.setActive(!zenMode); } void resetGame() { score = 0; gameTime = 0.0; gameActive = true; _updateScore(); _updateTimer(); // Clear all bubbles bubbleSpawner?.clearAllBubbles(); } void pauseGame() { paused = true; gameActive = false; audioManager.pauseBackgroundMusic(); _tiltDetector.stopListening(); } void resumeGame() { paused = false; gameActive = true; audioManager.resumeBackgroundMusic(); _initializeTiltDetection(); } /// End the game session and submit results to Google Play Games Future endGameSession() async { gameActive = false; final currentTime = DateTime.now().millisecondsSinceEpoch / 1000.0; final sessionLength = currentTime - _sessionStartTime; // Check for zen mode achievements if (isZenMode) { await _gamesManager.checkZenModeAchievements(sessionLength); } // Submit final results to Google Play Games await _gamesManager.submitGameResults( finalScore: score, isZenMode: isZenMode, totalBubblesPopped: _totalBubblesPopped, sessionLength: sessionLength, ); // Score is already tracked by ScoreManager.addScore() calls during gameplay } void handleShake() { if (!gameActive) return; // Get existing bubbles before spawning new ones final existingBubbles = bubbleSpawner?.getActiveBubbles().toList() ?? []; // Single bubble per shake rule: spawn only ONE bubble per shake // Find a safe position that doesn't overlap with existing bubbles final safePosition = _findSafeSpawnPosition(); if (safePosition != null) { bubbleSpawner?.spawnBubbleAt(safePosition); } // Only shake the existing bubbles (not the newly spawned ones) _shakeExistingBubbles(existingBubbles); } void _shakeExistingBubbles(List existingBubbles) { // Only apply shake effects to bubbles that existed before this shake for (final bubble in existingBubbles) { if (!bubble.isPopping) { bubble.addShakeEffect(); } } } /// Find a safe position to spawn a bubble that doesn't overlap with existing bubbles Vector2? _findSafeSpawnPosition() { const margin = 120.0; const minDistance = 80.0; // Minimum distance from other bubbles const maxAttempts = 20; // Maximum attempts to find a safe position final random = Random(); final activeBubbles = bubbleSpawner?.getActiveBubbles() ?? []; for (int attempt = 0; attempt < maxAttempts; attempt++) { // Generate random position within bounds final position = Vector2( margin + random.nextDouble() * (size.x - 2 * margin), margin + 100 + random.nextDouble() * (size.y - 2 * margin - 100), ); // Check if position is safe from all existing bubbles bool isSafe = true; for (final bubble in activeBubbles) { if (!bubble.isPopping) { final distance = position.distanceTo(bubble.position); if (distance < minDistance) { isSafe = false; break; } } } if (isSafe) { return position; } } // If no safe position found, return null (don't spawn) return null; } @override void onRemove() { // End the game session and submit results endGameSession(); audioManager.stopBackgroundMusic(); _tiltDetector.stopListening(); super.onRemove(); } }