diff --git a/README.md b/README.md index 3e4bad7..eb4281f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A modern falling block puzzle game for Android, featuring smooth animations, res - Hard drop and soft drop controls - Advanced move detection (e.g., T-Spins) and scoring - Persistent high score system (Top 5) +- Global leaderboard via Google Play Games Services ### Modern Android Features - Optimized for Android 11+ (API 30+) @@ -20,6 +21,7 @@ A modern falling block puzzle game for Android, featuring smooth animations, res - Automatic Dark theme support - Intuitive and responsive touch controls - Full edge-to-edge display utilization +- Google Play Games Services integration for leaderboards ### Scoring System @@ -74,6 +76,12 @@ Pixelmint Drop features a comprehensive scoring system designed to reward skillf - Multiple theme options with ability to change manually or enable Random Mode (unlocked when 2+ themes are available). - In Random Mode, themes change automatically every 10 line clears (1 level). +### Online Features +- Global leaderboard support through Google Play Games Services +- Sign in with your Google account to compete with players worldwide +- Your highest scores are automatically submitted to the leaderboard +- View the global leaderboard directly from the game + ## Technical Details ### Requirements diff --git a/app/build.gradle b/app/build.gradle index 0466731..872d5a3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.pixelmintdrop" minSdk 30 targetSdk 35 - versionCode 2 - versionName "0.1" + versionCode 5 + versionName "0.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -63,6 +63,8 @@ dependencies { implementation 'androidx.window:window:1.2.0' // For better display support implementation 'androidx.window:window-java:1.2.0' implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.google.android.gms:play-services-games-v2:19.0.0' + implementation 'com.google.android.gms:play-services-auth:20.7.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f3d41de..911f971 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + + Color.WHITE } - // Apply theme to back button + // Apply theme to buttons binding.backButton.setTextColor(textColor) // Update adapter theme @@ -122,6 +132,35 @@ class HighScoresActivity : AppCompatActivity() { Log.e("HighScoresActivity", "Error updating high scores", e) } } + + private fun showGlobalLeaderboard() { + try { + if (!highScoreManager.isGooglePlaySignedIn()) { + Toast.makeText(this, "Signing in to Google Play Games...", Toast.LENGTH_SHORT).show() + } + + // Call the updated showLeaderboard with a listener + highScoreManager.getGooglePlayGamesManager().showLeaderboard(this, + object : GooglePlayGamesManager.LeaderboardIntentListener { + override fun onLeaderboardIntentSuccess(intent: Intent) { + try { + startActivityForResult(intent, RC_LEADERBOARD_UI_HS) + } catch (e: Exception) { + Log.e("HighScoresActivity", "Failed to start leaderboard activity for result", e) + Toast.makeText(this@HighScoresActivity, "Could not display leaderboard.", Toast.LENGTH_SHORT).show() + } + } + + override fun onLeaderboardIntentFailure(exception: Exception) { + Log.e("HighScoresActivity", "Failed to get leaderboard intent.", exception) + Toast.makeText(this@HighScoresActivity, "Could not display leaderboard.", Toast.LENGTH_SHORT).show() + } + }) + } catch (e: Exception) { + Log.e("HighScoresActivity", "Error showing leaderboard", e) + Toast.makeText(this, "Could not open leaderboard", Toast.LENGTH_SHORT).show() + } + } override fun onResume() { super.onResume() @@ -143,6 +182,11 @@ class HighScoresActivity : AppCompatActivity() { finish() return true } + KeyEvent.KEYCODE_BUTTON_Y -> { + // Y button shows global leaderboard + showGlobalLeaderboard() + return true + } } } diff --git a/app/src/main/java/com/pixelmintdrop/MainActivity.kt b/app/src/main/java/com/pixelmintdrop/MainActivity.kt index 5701d19..1bf5875 100644 --- a/app/src/main/java/com/pixelmintdrop/MainActivity.kt +++ b/app/src/main/java/com/pixelmintdrop/MainActivity.kt @@ -55,6 +55,14 @@ import androidx.core.view.updatePadding import kotlin.math.max import kotlin.math.min import kotlin.random.Random +// Google Play Games Services imports +import com.google.android.gms.games.PlayGames +import com.google.android.gms.games.PlayGamesSdk +import com.google.android.gms.games.GamesSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignIn // Added for silent sign-in +import com.google.android.gms.auth.api.signin.GoogleSignInClient // Added for silent sign-in +import com.google.android.gms.auth.api.signin.GoogleSignInOptions // Added for silent sign-in +import com.pixelmintdrop.model.GooglePlayGamesManager // Added import for listener class MainActivity : AppCompatActivity(), GamepadController.GamepadConnectionListener, @@ -63,11 +71,16 @@ class MainActivity : AppCompatActivity(), companion object { private const val TAG = "MainActivity" + private const val RC_LEADERBOARD_UI = 9004 // Request code for leaderboard UI } // ViewModel private val viewModel: MainActivityViewModel by viewModels() // Added ViewModel + // Google Play Games Services + private lateinit var gamesSignInClient: GamesSignInClient + private lateinit var googleSignInClient: GoogleSignInClient // Added for silent sign-in + // UI components private lateinit var binding: ActivityMainBinding private lateinit var gameView: GameView @@ -157,6 +170,16 @@ class MainActivity : AppCompatActivity(), binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + // Initialize Google Play Games Services + PlayGamesSdk.initialize(this) + gamesSignInClient = PlayGames.getGamesSignInClient(this) + // Initialize GoogleSignInClient for silent sign-in + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_GAMES_SIGN_IN).build() + googleSignInClient = GoogleSignIn.getClient(this, signInOptions) + + // Request sign-in silently + signInToPlayGames() + // Store initial padding values before applying insets val initialPausePadding = Rect(binding.pauseContainer.paddingLeft, binding.pauseContainer.paddingTop, binding.pauseContainer.paddingRight, binding.pauseContainer.paddingBottom) @@ -450,6 +473,10 @@ class MainActivity : AppCompatActivity(), Log.d(TAG, "[GameOverDebug] Triples: $lastSessionTriples (from manager: ${statsManager.getSessionTriples()})") Log.d(TAG, "[GameOverDebug] Quads: $lastSessionQuads (from manager: ${statsManager.getSessionQuads()})") + // *** Submit score to Google Play Games *** + Log.d(TAG, "Attempting to submit end-game score to Play Games: $lastSessionScore") + highScoreManager.getGooglePlayGamesManager().submitScore(lastSessionScore.toLong(), this@MainActivity) + // End the session (updates lifetime stats) statsManager.endSession() @@ -529,9 +556,16 @@ class MainActivity : AppCompatActivity(), startGame() } + // High Scores button binding.highScoresButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - showHighScores() + binding.pauseContainer.visibility = View.GONE + val intent = Intent(this, HighScoresActivity::class.java) + startActivity(intent) + } + + // Leaderboard button + binding.leaderboardButton?.setOnClickListener { + showLeaderboard() } binding.pauseLevelUpButton.setOnClickListener { @@ -815,6 +849,7 @@ class MainActivity : AppCompatActivity(), binding.pauseRestartButton.setTextColor(themeColor) binding.resumeButton.setTextColor(themeColor) binding.highScoresButton.setTextColor(themeColor) + binding.leaderboardButton?.setTextColor(themeColor) binding.statsButton.setTextColor(themeColor) binding.pauseLevelUpButton?.setTextColor(themeColor) // Safe call binding.pauseLevelDownButton?.setTextColor(themeColor) // Safe call @@ -1046,6 +1081,10 @@ class MainActivity : AppCompatActivity(), Log.d(TAG, "[GameOverDebug] Triples: $lastSessionTriples (from manager: ${statsManager.getSessionTriples()})") Log.d(TAG, "[GameOverDebug] Quads: $lastSessionQuads (from manager: ${statsManager.getSessionQuads()})") + // *** Submit score to Google Play Games *** + Log.d(TAG, "Attempting to submit end-game score to Play Games: $lastSessionScore") + highScoreManager.getGooglePlayGamesManager().submitScore(lastSessionScore.toLong(), this@MainActivity) + // End the session (updates lifetime stats) statsManager.endSession() @@ -1640,6 +1679,7 @@ class MainActivity : AppCompatActivity(), // Group 2: Stats and Scoring orderedViews.add(binding.highScoresButton) + orderedViews.add(binding.leaderboardButton) orderedViews.add(binding.statsButton) // Group 3: Level selection (use safe calls) @@ -2206,4 +2246,82 @@ class MainActivity : AppCompatActivity(), Log.d("RandomMode", "Cannot apply random theme/skin - no unlocked options available") } } + + /** + * Handles sign-in to Google Play Games Services + */ + private fun signInToPlayGames() { + Log.d(TAG, "Attempting to sign into Google Play Games") + gamesSignInClient.isAuthenticated.addOnCompleteListener { task -> + val isAuthenticated = task.isSuccessful && task.result.isAuthenticated + + if (!isAuthenticated) { + // Silent sign-in failed, but don't prompt right away + Log.d(TAG, "Not authenticated with Play Games, will prompt for sign-in when needed") + + // Optional: Add a sign-in button to your menu or UI for manual sign-in + // We'll attempt sign-in when accessing leaderboards or submitting scores + } else { + Log.d(TAG, "Already authenticated with Play Games") + + // If we were already authenticated, make sure scores are synced + val lastHighScore = highScoreManager.getHighScores().maxByOrNull { it.score } + lastHighScore?.let { + // Submit the high score if we're already authenticated + Log.d(TAG, "User is signed in, submitting last high score: ${it.score}") + highScoreManager.getGooglePlayGamesManager().submitScore(it.score.toLong(), this) + } + } + } + } + + // Method to show Google Play Games leaderboard + private fun showLeaderboard() { + // New logic: Try silent sign-in first, then interactive if needed. + Log.d(TAG, "Attempting silent sign-in before showing leaderboard...") + // Use googleSignInClient for silentSignIn + googleSignInClient.silentSignIn().addOnCompleteListener { silentSignInTask -> + if (silentSignInTask.isSuccessful) { + // Silent sign-in successful OR user already signed in. + Log.d(TAG, "Silent sign-in successful/already signed in. Getting leaderboard intent.") + getAndShowLeaderboardIntent() + } else { + // Silent sign-in failed. Need explicit sign-in. + Log.w(TAG, "Silent sign-in failed. Attempting interactive sign-in.", silentSignInTask.exception) + // Use gamesSignInClient for interactive signIn + gamesSignInClient.signIn().addOnCompleteListener { interactiveSignInTask -> + if (interactiveSignInTask.isSuccessful) { + // Interactive sign-in successful + Log.d(TAG, "Interactive sign-in successful. Getting leaderboard intent.") + getAndShowLeaderboardIntent() + } else { + // Interactive sign-in failed + Log.e(TAG, "Interactive sign-in failed.", interactiveSignInTask.exception) + Toast.makeText(this, "Sign-in failed. Please try again.", Toast.LENGTH_SHORT).show() + } + } + } + } + } + + // Helper function to get the intent from GooglePlayGamesManager and show it + private fun getAndShowLeaderboardIntent() { + highScoreManager.getGooglePlayGamesManager().showLeaderboard(this, + object : GooglePlayGamesManager.LeaderboardIntentListener { + override fun onLeaderboardIntentSuccess(intent: Intent) { + Log.d(TAG, "Received leaderboard intent, starting activity for result.") + try { + startActivityForResult(intent, RC_LEADERBOARD_UI) + } catch (e: Exception) { + Log.e(TAG, "Failed to start leaderboard activity for result", e) + Toast.makeText(this@MainActivity, "Could not display leaderboard.", Toast.LENGTH_SHORT).show() + } + } + + override fun onLeaderboardIntentFailure(exception: Exception) { + Log.e(TAG, "Failed to get leaderboard intent.", exception) + Toast.makeText(this@MainActivity, "Could not display leaderboard.", Toast.LENGTH_SHORT).show() + } + }) + } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/model/GooglePlayGamesManager.kt b/app/src/main/java/com/pixelmintdrop/model/GooglePlayGamesManager.kt new file mode 100644 index 0000000..d88229a --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/model/GooglePlayGamesManager.kt @@ -0,0 +1,169 @@ +package com.pixelmintdrop.model + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.games.PlayGames +import com.google.android.gms.games.PlayGamesSdk +import com.google.android.gms.tasks.Task + +class GooglePlayGamesManager(private val context: Context) { + private val TAG = "GooglePlayGamesManager" + + // Define a listener interface for the leaderboard intent + interface LeaderboardIntentListener { + fun onLeaderboardIntentSuccess(intent: Intent) + fun onLeaderboardIntentFailure(exception: Exception) + } + + // Leaderboard ID + companion object { + const val LEADERBOARD_ID = "CgkImJW2mKsSEAIQAQ" + } + + // Initialize the Play Games SDK + init { + try { + PlayGamesSdk.initialize(context) + Log.d(TAG, "PlayGamesSdk initialized") + } catch (e: Exception) { + Log.e(TAG, "Error initializing PlayGamesSdk", e) + } + } + + // Check if user is already signed in + fun isSignedIn(): Boolean { + val account = GoogleSignIn.getLastSignedInAccount(context) + return account != null && !account.isExpired + } + + // Get the sign-in client + fun getSignInClient(): GoogleSignInClient { + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_GAMES_SIGN_IN).build() + return GoogleSignIn.getClient(context, signInOptions) + } + + // Handle sign-in result + fun handleSignInResult(task: Task): Boolean { + return if (task.isSuccessful) { + Log.d(TAG, "Sign-in successful") + true + } else { + Log.e(TAG, "Sign-in failed", task.exception) + false + } + } + + // Submit score to leaderboard (requires an activity for potential sign-in) + fun submitScore(score: Long, activity: Activity? = null) { + if (activity == null) { + Log.w(TAG, "Activity context is required for score submission.") + return + } + + Log.d(TAG, "Attempting to submit score: $score") + + // Always force a sign-in to ensure we have a valid account + val signInClient = PlayGames.getGamesSignInClient(activity) + signInClient.signIn().addOnCompleteListener { signInTask -> + if (signInTask.isSuccessful) { + Log.d(TAG, "Sign-in successful, retrieving account for score submission") + + // After sign-in, get the account and submit score + try { + // Use PlayGames API directly to get client and submit score + val leaderboardsClient = PlayGames.getLeaderboardsClient(activity) + leaderboardsClient.submitScore(LEADERBOARD_ID, score) + Log.d(TAG, "Successfully submitted score $score to leaderboard $LEADERBOARD_ID") + + // Show confirmation to user + android.widget.Toast.makeText( + activity, + "Score submitted to leaderboard", + android.widget.Toast.LENGTH_SHORT + ).show() + } catch (e: Exception) { + Log.e(TAG, "Error submitting score after sign-in", e) + android.widget.Toast.makeText( + activity, + "Failed to submit score to leaderboard", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } else { + Log.e(TAG, "Sign-in failed, unable to submit score", signInTask.exception) + android.widget.Toast.makeText( + activity, + "Sign-in required to submit score to leaderboard", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + } + + // Show the leaderboard - Now uses a listener to return the Intent + fun showLeaderboard(activity: Activity, listener: LeaderboardIntentListener) { + Log.d(TAG, "showLeaderboard called in GooglePlayGamesManager") + + // First check if already authenticated + PlayGames.getGamesSignInClient(activity).isAuthenticated.addOnCompleteListener { authTask -> + val isAuthenticated = authTask.isSuccessful && authTask.result.isAuthenticated + + if (isAuthenticated) { + // Already authenticated, get leaderboard + getLeaderboardIntent(activity, listener) + } else { + // Need to authenticate first + Log.d(TAG, "User not authenticated for leaderboard, attempting sign-in") + PlayGames.getGamesSignInClient(activity).signIn().addOnCompleteListener { signInTask -> + if (signInTask.isSuccessful) { + // Successfully signed in, now get leaderboard + Log.d(TAG, "Sign-in successful, getting leaderboard") + getLeaderboardIntent(activity, listener) + } else { + // Failed to sign in + Log.e(TAG, "Failed to sign in for leaderboard access", signInTask.exception) + listener.onLeaderboardIntentFailure( + Exception("Sign-in required to view leaderboard", signInTask.exception) + ) + + // Inform user + android.widget.Toast.makeText( + activity, + "Sign-in required to view leaderboard", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + + // Private helper to get leaderboard intent + private fun getLeaderboardIntent(activity: Activity, listener: LeaderboardIntentListener) { + Log.d(TAG, "Attempting to get LeaderboardsClient") + try { + val leaderboardsClient = PlayGames.getLeaderboardsClient(activity) + Log.d(TAG, "LeaderboardsClient obtained, attempting to get leaderboard intent for ID: $LEADERBOARD_ID") + leaderboardsClient + .getLeaderboardIntent(LEADERBOARD_ID) + .addOnSuccessListener { intent -> + Log.d(TAG, "Successfully obtained leaderboard intent. Calling listener.") + listener.onLeaderboardIntentSuccess(intent) + } + .addOnFailureListener { e -> + Log.e(TAG, "Error getting leaderboard intent", e) + listener.onLeaderboardIntentFailure(e) + } + } catch (e: Exception) { + Log.e(TAG, "Error obtaining LeaderboardsClient or getting leaderboard intent", e) + listener.onLeaderboardIntentFailure(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt b/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt index bf35f52..de48594 100644 --- a/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt +++ b/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt @@ -10,6 +10,7 @@ class HighScoreManager(private val context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val gson = Gson() private val type: Type = object : TypeToken>() {}.type + private val googlePlayGamesManager = GooglePlayGamesManager(context) companion object { private const val PREFS_NAME = "pixelmintdrop_highscores" @@ -27,6 +28,7 @@ class HighScoreManager(private val context: Context) { } fun addHighScore(highScore: HighScore) { + // Save to local high scores val currentScores = getHighScores().toMutableList() currentScores.add(highScore) @@ -37,6 +39,9 @@ class HighScoreManager(private val context: Context) { // Save to SharedPreferences val json = gson.toJson(topScores) prefs.edit().putString(KEY_HIGHSCORES, json).apply() + + // Submit to Google Play Games leaderboard if signed in + submitScoreToGooglePlay(highScore.score) } fun isHighScore(score: Int): Boolean { @@ -44,4 +49,26 @@ class HighScoreManager(private val context: Context) { return currentScores.size < MAX_HIGHSCORES || score > (currentScores.lastOrNull()?.score ?: 0) } + + // Submit score to Google Play Games leaderboard + private fun submitScoreToGooglePlay(score: Int) { + // We don't have an activity here, so we can't submit the score directly + // The score will be submitted later when an activity is available + try { + googlePlayGamesManager.submitScore(score.toLong(), null) + } catch (e: Exception) { + // Log error but don't crash + android.util.Log.e("HighScoreManager", "Error submitting score to leaderboard", e) + } + } + + // Method to check if user is signed in to Google Play Games + fun isGooglePlaySignedIn(): Boolean { + return googlePlayGamesManager.isSignedIn() + } + + // Get Google Play Games manager + fun getGooglePlayGamesManager(): GooglePlayGamesManager { + return googlePlayGamesManager + } } \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 715a14d..13f79f2 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -438,6 +438,20 @@ android:singleLine="true" android:focusable="true" android:focusableInTouchMode="true" /> + + +