Enhance: Integrate Google Play Games Services for global leaderboard

- Added global leaderboard functionality via Google Play Games Services.
- Implemented sign-in process for Google Play Games and score submission.
- Updated UI to include leaderboard buttons in both MainActivity and HighScoresActivity.
- Enhanced README to document new online features and leaderboard integration.
- Added necessary dependencies in build.gradle and updated AndroidManifest.xml for Google Play Games configuration.
This commit is contained in:
cmclark00 2025-04-02 14:32:09 -04:00
parent 21b2513ad4
commit 220caa39f7
10 changed files with 279 additions and 12 deletions

View file

@ -11,6 +11,7 @@ A modern falling block puzzle game for Android, featuring smooth animations, res
- Hard drop and soft drop controls - Hard drop and soft drop controls
- Advanced move detection (e.g., T-Spins) and scoring - Advanced move detection (e.g., T-Spins) and scoring
- Persistent high score system (Top 5) - Persistent high score system (Top 5)
- Global leaderboard via Google Play Games Services
### Modern Android Features ### Modern Android Features
- Optimized for Android 11+ (API 30+) - 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 - Automatic Dark theme support
- Intuitive and responsive touch controls - Intuitive and responsive touch controls
- Full edge-to-edge display utilization - Full edge-to-edge display utilization
- Google Play Games Services integration for leaderboards
### Scoring System ### 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). - 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). - 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 ## Technical Details
### Requirements ### Requirements

View file

@ -63,6 +63,8 @@ dependencies {
implementation 'androidx.window:window:1.2.0' // For better display support implementation 'androidx.window:window:1.2.0' // For better display support
implementation 'androidx.window:window-java:1.2.0' implementation 'androidx.window:window-java:1.2.0'
implementation 'com.google.code.gson:gson:2.10.1' 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' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View file

@ -11,6 +11,9 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.pixelmintdrop"> android:theme="@style/Theme.pixelmintdrop">
<meta-data
android:name="com.google.android.gms.games.APP_ID"
android:value="@string/app_id" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View file

@ -13,6 +13,7 @@ import android.view.KeyEvent
import android.view.InputDevice import android.view.InputDevice
import android.graphics.Rect import android.graphics.Rect
import android.view.View import android.view.View
import android.widget.Toast
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -68,6 +69,11 @@ class HighScoresActivity : AppCompatActivity() {
binding.backButton.setOnClickListener { binding.backButton.setOnClickListener {
finish() finish()
} }
// Set up leaderboard button
binding.leaderboardButton?.setOnClickListener {
showGlobalLeaderboard()
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("HighScoresActivity", "Error in onCreate", e) Log.e("HighScoresActivity", "Error in onCreate", e)
// Show an error message if necessary, or finish gracefully // Show an error message if necessary, or finish gracefully
@ -104,8 +110,9 @@ class HighScoresActivity : AppCompatActivity() {
else -> Color.WHITE else -> Color.WHITE
} }
// Apply theme to back button // Apply theme to buttons
binding.backButton.setTextColor(textColor) binding.backButton.setTextColor(textColor)
binding.leaderboardButton?.setTextColor(textColor)
// Update adapter theme // Update adapter theme
highScoreAdapter.applyTheme(themeId) highScoreAdapter.applyTheme(themeId)
@ -122,6 +129,20 @@ class HighScoresActivity : AppCompatActivity() {
Log.e("HighScoresActivity", "Error updating high scores", e) 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()
}
// Show the leaderboard
highScoreManager.getGooglePlayGamesManager().showLeaderboard(this)
} catch (e: Exception) {
Log.e("HighScoresActivity", "Error showing leaderboard", e)
Toast.makeText(this, "Could not open leaderboard", Toast.LENGTH_SHORT).show()
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -143,6 +164,11 @@ class HighScoresActivity : AppCompatActivity() {
finish() finish()
return true return true
} }
KeyEvent.KEYCODE_BUTTON_Y -> {
// Y button shows global leaderboard
showGlobalLeaderboard()
return true
}
} }
} }

View file

@ -55,6 +55,10 @@ import androidx.core.view.updatePadding
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.random.Random 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
class MainActivity : AppCompatActivity(), class MainActivity : AppCompatActivity(),
GamepadController.GamepadConnectionListener, GamepadController.GamepadConnectionListener,
@ -68,6 +72,9 @@ class MainActivity : AppCompatActivity(),
// ViewModel // ViewModel
private val viewModel: MainActivityViewModel by viewModels() // Added ViewModel private val viewModel: MainActivityViewModel by viewModels() // Added ViewModel
// Google Play Games Services
private lateinit var gamesSignInClient: GamesSignInClient
// UI components // UI components
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var gameView: GameView private lateinit var gameView: GameView
@ -157,6 +164,13 @@ class MainActivity : AppCompatActivity(),
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// Initialize Google Play Games Services
PlayGamesSdk.initialize(this)
gamesSignInClient = PlayGames.getGamesSignInClient(this)
// Request sign-in silently
signInToPlayGames()
// Store initial padding values before applying insets // Store initial padding values before applying insets
val initialPausePadding = Rect(binding.pauseContainer.paddingLeft, binding.pauseContainer.paddingTop, val initialPausePadding = Rect(binding.pauseContainer.paddingLeft, binding.pauseContainer.paddingTop,
binding.pauseContainer.paddingRight, binding.pauseContainer.paddingBottom) binding.pauseContainer.paddingRight, binding.pauseContainer.paddingBottom)
@ -529,9 +543,16 @@ class MainActivity : AppCompatActivity(),
startGame() startGame()
} }
// High Scores button
binding.highScoresButton.setOnClickListener { binding.highScoresButton.setOnClickListener {
gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) binding.pauseContainer.visibility = View.GONE
showHighScores() val intent = Intent(this, HighScoresActivity::class.java)
startActivity(intent)
}
// Leaderboard button
binding.leaderboardButton?.setOnClickListener {
showLeaderboard()
} }
binding.pauseLevelUpButton.setOnClickListener { binding.pauseLevelUpButton.setOnClickListener {
@ -815,6 +836,7 @@ class MainActivity : AppCompatActivity(),
binding.pauseRestartButton.setTextColor(themeColor) binding.pauseRestartButton.setTextColor(themeColor)
binding.resumeButton.setTextColor(themeColor) binding.resumeButton.setTextColor(themeColor)
binding.highScoresButton.setTextColor(themeColor) binding.highScoresButton.setTextColor(themeColor)
binding.leaderboardButton?.setTextColor(themeColor)
binding.statsButton.setTextColor(themeColor) binding.statsButton.setTextColor(themeColor)
binding.pauseLevelUpButton?.setTextColor(themeColor) // Safe call binding.pauseLevelUpButton?.setTextColor(themeColor) // Safe call
binding.pauseLevelDownButton?.setTextColor(themeColor) // Safe call binding.pauseLevelDownButton?.setTextColor(themeColor) // Safe call
@ -1640,6 +1662,7 @@ class MainActivity : AppCompatActivity(),
// Group 2: Stats and Scoring // Group 2: Stats and Scoring
orderedViews.add(binding.highScoresButton) orderedViews.add(binding.highScoresButton)
orderedViews.add(binding.leaderboardButton)
orderedViews.add(binding.statsButton) orderedViews.add(binding.statsButton)
// Group 3: Level selection (use safe calls) // Group 3: Level selection (use safe calls)
@ -2206,4 +2229,50 @@ class MainActivity : AppCompatActivity(),
Log.d("RandomMode", "Cannot apply random theme/skin - no unlocked options available") Log.d("RandomMode", "Cannot apply random theme/skin - no unlocked options available")
} }
} }
/**
* Handles sign-in to Google Play Games Services
*/
private fun signInToPlayGames() {
gamesSignInClient.isAuthenticated.addOnCompleteListener { task ->
val isAuthenticated = task.isSuccessful && task.result.isAuthenticated
if (!isAuthenticated) {
// Silent sign-in failed, offer to sign in explicitly
Log.d(TAG, "Not authenticated with Play Games, will prompt user later")
// We don't show UI for this immediately, we'll add a button to the pause menu
} 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 {
highScoreManager.getGooglePlayGamesManager().submitScore(it.score.toLong(), this)
}
}
}
}
// Method to show Google Play Games leaderboard
private fun showLeaderboard() {
gamesSignInClient.isAuthenticated.addOnCompleteListener { task ->
val isAuthenticated = task.isSuccessful && task.result.isAuthenticated
if (isAuthenticated) {
// Show leaderboard
highScoreManager.getGooglePlayGamesManager().showLeaderboard(this)
} else {
// Sign in first
gamesSignInClient.signIn().addOnCompleteListener { signInTask ->
if (signInTask.isSuccessful) {
// Now try to show leaderboard again
highScoreManager.getGooglePlayGamesManager().showLeaderboard(this)
} else {
Toast.makeText(this, "Sign-in required to view leaderboard", Toast.LENGTH_SHORT).show()
}
}
}
}
}
} }

View file

@ -0,0 +1,96 @@
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"
// 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<GoogleSignInAccount>): 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) {
val account = GoogleSignIn.getLastSignedInAccount(context)
if (account == null) {
Log.w(TAG, "Not signed in, score will not be submitted to Google Play Games")
return
}
try {
val leaderboardsClient = PlayGames.getLeaderboardsClient(activity ?: return)
leaderboardsClient.submitScore(LEADERBOARD_ID, score)
Log.d(TAG, "Score $score submitted to leaderboard $LEADERBOARD_ID")
} catch (e: Exception) {
Log.e(TAG, "Error submitting score to leaderboard", e)
}
}
// Show the leaderboard
fun showLeaderboard(activity: Activity) {
val account = GoogleSignIn.getLastSignedInAccount(context)
if (account == null) {
Log.w(TAG, "Not signed in, cannot show leaderboard")
return
}
try {
val leaderboardsClient = PlayGames.getLeaderboardsClient(activity)
leaderboardsClient
.getLeaderboardIntent(LEADERBOARD_ID)
.addOnSuccessListener { intent ->
activity.startActivity(intent)
}
.addOnFailureListener { e ->
Log.e(TAG, "Error showing leaderboard", e)
}
} catch (e: Exception) {
Log.e(TAG, "Error showing leaderboard", e)
}
}
}

View file

@ -10,6 +10,7 @@ class HighScoreManager(private val context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val gson = Gson() private val gson = Gson()
private val type: Type = object : TypeToken<List<HighScore>>() {}.type private val type: Type = object : TypeToken<List<HighScore>>() {}.type
private val googlePlayGamesManager = GooglePlayGamesManager(context)
companion object { companion object {
private const val PREFS_NAME = "pixelmintdrop_highscores" private const val PREFS_NAME = "pixelmintdrop_highscores"
@ -27,6 +28,7 @@ class HighScoreManager(private val context: Context) {
} }
fun addHighScore(highScore: HighScore) { fun addHighScore(highScore: HighScore) {
// Save to local high scores
val currentScores = getHighScores().toMutableList() val currentScores = getHighScores().toMutableList()
currentScores.add(highScore) currentScores.add(highScore)
@ -37,6 +39,9 @@ class HighScoreManager(private val context: Context) {
// Save to SharedPreferences // Save to SharedPreferences
val json = gson.toJson(topScores) val json = gson.toJson(topScores)
prefs.edit().putString(KEY_HIGHSCORES, json).apply() prefs.edit().putString(KEY_HIGHSCORES, json).apply()
// Submit to Google Play Games leaderboard if signed in
submitScoreToGooglePlay(highScore.score)
} }
fun isHighScore(score: Int): Boolean { fun isHighScore(score: Int): Boolean {
@ -44,4 +49,26 @@ class HighScoreManager(private val context: Context) {
return currentScores.size < MAX_HIGHSCORES || return currentScores.size < MAX_HIGHSCORES ||
score > (currentScores.lastOrNull()?.score ?: 0) 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
}
} }

View file

@ -406,6 +406,19 @@
android:textStyle="bold" android:textStyle="bold"
android:fontFamily="sans-serif" android:fontFamily="sans-serif"
android:textAllCaps="false" /> android:textAllCaps="false" />
<Button
android:id="@+id/leaderboardButton"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@color/transparent"
android:text="Global Leaderboard"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="sans-serif"
android:textAllCaps="false" />
<Button <Button
android:id="@+id/statsButton" android:id="@+id/statsButton"

View file

@ -25,15 +25,37 @@
android:layout_weight="1" android:layout_weight="1"
android:layout_marginBottom="16dp"/> android:layout_marginBottom="16dp"/>
<Button <LinearLayout
android:id="@+id/backButton" android:layout_width="match_parent"
android:layout_width="200dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:orientation="horizontal"
android:background="@color/transparent" android:gravity="center">
android:text="@string/back"
android:textColor="@color/white" <Button
android:textSize="18sp" android:id="@+id/backButton"
android:fontFamily="monospace"/> android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="center"
android:layout_marginEnd="8dp"
android:background="@color/transparent"
android:text="@string/back"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"/>
<Button
android:id="@+id/leaderboardButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="center"
android:layout_marginStart="8dp"
android:background="@color/transparent"
android:text="Global Leaderboard"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"/>
</LinearLayout>
</LinearLayout> </LinearLayout>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Pixel Mint Drop</string> <string name="app_name">Pixel Mint Drop</string>
<string name="app_id">630069234328</string>
<string name="game_over">game over</string> <string name="game_over">game over</string>
<string name="score">score</string> <string name="score">score</string>
<string name="level">level</string> <string name="level">level</string>