Add high score system with persistent storage using SharedPreferences and Gson

This commit is contained in:
cmclark00 2025-03-27 00:27:44 -04:00
parent 8dc1d433ea
commit b068de76f5
17 changed files with 480 additions and 168 deletions

View file

@ -49,6 +49,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
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'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View file

@ -20,5 +20,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".HighScoreEntryActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:exported="false" />
<activity
android:name=".HighScoresActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,39 @@
package com.mintris
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.mintris.model.HighScore
import com.mintris.model.HighScoreManager
class HighScoreEntryActivity : AppCompatActivity() {
private lateinit var highScoreManager: HighScoreManager
private lateinit var nameInput: EditText
private lateinit var scoreText: TextView
private lateinit var saveButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.high_score_entry)
highScoreManager = HighScoreManager(this)
nameInput = findViewById(R.id.nameInput)
scoreText = findViewById(R.id.scoreText)
saveButton = findViewById(R.id.saveButton)
val score = intent.getIntExtra("score", 0)
val level = intent.getIntExtra("level", 1)
scoreText.text = getString(R.string.score) + ": $score"
saveButton.setOnClickListener {
val name = nameInput.text.toString().trim()
if (name.isNotEmpty()) {
val highScore = HighScore(name, score, level)
highScoreManager.addHighScore(highScore)
finish()
}
}
}
}

View file

@ -0,0 +1,45 @@
package com.mintris
import android.os.Bundle
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.mintris.model.HighScoreAdapter
import com.mintris.model.HighScoreManager
class HighScoresActivity : AppCompatActivity() {
private lateinit var highScoreManager: HighScoreManager
private lateinit var highScoreAdapter: HighScoreAdapter
private lateinit var highScoresList: RecyclerView
private lateinit var backButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.high_scores)
highScoreManager = HighScoreManager(this)
highScoresList = findViewById(R.id.highScoresList)
backButton = findViewById(R.id.backButton)
highScoreAdapter = HighScoreAdapter()
highScoresList.layoutManager = LinearLayoutManager(this)
highScoresList.adapter = highScoreAdapter
updateHighScores()
backButton.setOnClickListener {
finish()
}
}
private fun updateHighScores() {
val scores = highScoreManager.getHighScores()
highScoreAdapter.updateHighScores(scores)
}
override fun onResume() {
super.onResume()
updateHighScores()
}
}

View file

@ -19,6 +19,8 @@ import com.mintris.game.TitleScreen
import android.view.HapticFeedbackConstants
import com.mintris.model.GameBoard
import com.mintris.audio.GameMusic
import com.mintris.model.HighScoreManager
import android.content.Intent
class MainActivity : AppCompatActivity() {
@ -29,12 +31,15 @@ class MainActivity : AppCompatActivity() {
private lateinit var gameBoard: GameBoard
private lateinit var gameMusic: GameMusic
private lateinit var titleScreen: TitleScreen
private lateinit var highScoreManager: HighScoreManager
// Game state
private var isSoundEnabled = true
private var isMusicEnabled = true
private var selectedLevel = 1
private val maxLevel = 20
private var currentScore = 0
private var currentLevel = 1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -47,6 +52,7 @@ class MainActivity : AppCompatActivity() {
gameView = binding.gameView
titleScreen = binding.titleScreen
gameMusic = GameMusic(this)
highScoreManager = HighScoreManager(this)
// Set up game view
gameView.setGameBoard(gameBoard)
@ -151,6 +157,11 @@ class MainActivity : AppCompatActivity() {
startGame()
}
binding.highScoresButton.setOnClickListener {
gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY)
showHighScores()
}
binding.pauseLevelUpButton.setOnClickListener {
gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY)
if (selectedLevel < maxLevel) {
@ -193,6 +204,16 @@ class MainActivity : AppCompatActivity() {
*/
private fun showGameOver(score: Int) {
binding.finalScoreText.text = getString(R.string.score) + ": " + score
// Check if this is a high score
if (highScoreManager.isHighScore(score)) {
val intent = Intent(this, HighScoreEntryActivity::class.java).apply {
putExtra("score", score)
putExtra("level", currentLevel)
}
startActivity(intent)
}
binding.gameOverContainer.visibility = View.VISIBLE
// Vibrate to indicate game over
@ -314,4 +335,12 @@ class MainActivity : AppCompatActivity() {
binding.pauseContainer.visibility = View.GONE
titleScreen.visibility = View.VISIBLE
}
/**
* Show high scores
*/
private fun showHighScores() {
val intent = Intent(this, HighScoresActivity::class.java)
startActivity(intent)
}
}

View file

@ -206,26 +206,9 @@ class GameView @JvmOverloads constructor(
// Move the current tetromino down automatically
gameBoard.moveDown()
// Check if lines need to be cleared
if (gameBoard.linesToClear.isNotEmpty()) {
// Trigger line clear callback for vibration
onLineClear?.invoke(gameBoard.linesToClear.size)
// Trigger line clearing on a background thread to prevent UI freezes
Thread {
// Process the line clearing off the UI thread
gameBoard.clearLinesFromGrid()
// Then update UI on the main thread
handler.post {
invalidate()
}
}.start()
} else {
// Update UI with current game state
onGameStateChanged?.invoke(gameBoard.score, gameBoard.level, gameBoard.lines)
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)

View file

@ -265,92 +265,58 @@ class GameBoard(
// Trigger the piece lock vibration
onPieceLock?.invoke()
// Find lines to clear
findLinesToClear()
// Find and clear lines immediately
findAndClearLines()
// Only spawn a new piece if we're not in the middle of clearing lines
if (!isLineClearAnimationInProgress) {
// Spawn new piece
spawnPiece()
}
// Allow holding piece again after locking
canHold = true
}
/**
* Find lines that should be cleared and store them
* Find and clear completed lines immediately
*/
private fun findLinesToClear() {
// Clear existing lines
linesToClear.clear()
private fun findAndClearLines() {
// Quick scan for completed lines
for (y in 0 until height) {
val row = grid[y]
// Check if line is full - use all() for better performance
if (row.all { it }) {
linesToClear.add(y)
}
}
// Sort lines from bottom to top for proper clearing
if (linesToClear.isNotEmpty()) {
linesToClear.sortDescending()
isLineClearAnimationInProgress = true
}
}
/**
* Prepare for line clearing animation
*/
fun finishLineClearingEffect() {
if (linesToClear.isNotEmpty() && !isLineClearAnimationInProgress) {
clearLinesFromGrid()
}
}
/**
* Actually clear the lines from the grid after animation
*/
fun clearLinesFromGrid() {
if (linesToClear.isNotEmpty()) {
val clearedLines = linesToClear.size
// Much faster approach: shift rows in-place without creating temporary arrays
// Pre-compute all row movements to minimize array operations
val rowMoves = IntArray(height) { -1 } // Where each row should move to
var shiftAmount = 0
var y = height - 1
// Calculate how much to shift each row
for (y in height - 1 downTo 0) {
if (y in linesToClear) {
while (y >= 0) {
if (grid[y].all { it }) {
// Line is full, increment shift amount
shiftAmount++
} else if (shiftAmount > 0) {
rowMoves[y] = y + shiftAmount
// Shift this row down by shiftAmount
System.arraycopy(grid[y], 0, grid[y + shiftAmount], 0, width)
}
y--
}
// Apply row shifts in a single pass, bottom to top
for (y in height - 1 downTo 0) {
val targetY = rowMoves[y]
if (targetY != -1 && targetY < height) {
// Shift this row down
System.arraycopy(grid[y], 0, grid[targetY], 0, width)
}
}
// Clear top rows (faster than creating a new array)
for (y in 0 until clearedLines) {
// Clear top rows
for (y in 0 until shiftAmount) {
java.util.Arrays.fill(grid[y], false)
}
// Calculate base score (NES scoring system)
// If lines were cleared, calculate score in background
if (shiftAmount > 0) {
Thread {
calculateScore(shiftAmount)
}.start()
}
}
/**
* Calculate score for cleared lines
*/
private fun calculateScore(clearedLines: Int) {
// Pre-calculated score multipliers for better performance
val baseScore = when (clearedLines) {
1 -> 40
2 -> 100
3 -> 300
4 -> 1200 // Tetris
4 -> 1200
else -> 0
}
@ -405,7 +371,10 @@ class GameBoard(
backToBackMultiplier * perfectClearMultiplier *
allClearMultiplier * tSpinMultiplier).toInt()
// Update score on main thread
Thread {
score += finalScore
}.start()
// Update combo counter
if (clearedLines > 0) {
@ -425,14 +394,6 @@ class GameBoard(
// Update game speed based on level (NES formula)
dropInterval = (1000 * Math.pow(0.8, (level - 1).toDouble())).toLong()
// Reset animation state immediately
isLineClearAnimationInProgress = false
linesToClear.clear()
// Now spawn the next piece after all lines are cleared
spawnPiece()
}
}
/**

View file

@ -0,0 +1,8 @@
package com.mintris.model
data class HighScore(
val name: String,
val score: Int,
val level: Int,
val date: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,42 @@
package com.mintris.model
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.mintris.R
class HighScoreAdapter : RecyclerView.Adapter<HighScoreAdapter.HighScoreViewHolder>() {
private var highScores: List<HighScore> = emptyList()
fun updateHighScores(newHighScores: List<HighScore>) {
highScores = newHighScores
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HighScoreViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_high_score, parent, false)
return HighScoreViewHolder(view)
}
override fun onBindViewHolder(holder: HighScoreViewHolder, position: Int) {
val highScore = highScores[position]
holder.bind(highScore, position + 1)
}
override fun getItemCount(): Int = highScores.size
class HighScoreViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val rankText: TextView = itemView.findViewById(R.id.rankText)
private val nameText: TextView = itemView.findViewById(R.id.nameText)
private val scoreText: TextView = itemView.findViewById(R.id.scoreText)
fun bind(highScore: HighScore, rank: Int) {
rankText.text = "#$rank"
nameText.text = highScore.name
scoreText.text = highScore.score.toString()
}
}
}

View file

@ -0,0 +1,47 @@
package com.mintris.model
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.lang.reflect.Type
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<List<HighScore>>() {}.type
companion object {
private const val PREFS_NAME = "mintris_highscores"
private const val KEY_HIGHSCORES = "highscores"
private const val MAX_HIGHSCORES = 5
}
fun getHighScores(): List<HighScore> {
val json = prefs.getString(KEY_HIGHSCORES, null)
return if (json != null) {
gson.fromJson(json, type)
} else {
emptyList()
}
}
fun addHighScore(highScore: HighScore) {
val currentScores = getHighScores().toMutableList()
currentScores.add(highScore)
// Sort by score (descending) and keep only top 5
currentScores.sortByDescending { it.score }
val topScores = currentScores.take(MAX_HIGHSCORES)
// Save to SharedPreferences
val json = gson.toJson(topScores)
prefs.edit().putString(KEY_HIGHSCORES, json).apply()
}
fun isHighScore(score: Int): Boolean {
val currentScores = getHighScores()
return currentScores.size < MAX_HIGHSCORES ||
score > (currentScores.lastOrNull()?.score ?: 0)
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#33FFFFFF" />
<corners android:radius="8dp" />
<stroke
android:width="2dp"
android:color="@color/white" />
</shape>

View file

@ -191,6 +191,16 @@
android:textColor="@color/white"
android:textSize="18sp" />
<Button
android:id="@+id/highScoresButton"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@color/transparent"
android:text="@string/high_scores"
android:textColor="@color/white"
android:textSize="18sp" />
<LinearLayout
android:id="@+id/levelSelectorContainer"
android:layout_width="wrap_content"

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/new_high_score"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="32dp"/>
<TextView
android:id="@+id/scoreText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="20sp"
android:fontFamily="monospace"
android:layout_marginBottom="16dp"/>
<EditText
android:id="@+id/nameInput"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:background="@drawable/edit_text_background"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"
android:gravity="center"
android:inputType="text"
android:maxLength="10"
android:layout_marginBottom="32dp"/>
<Button
android:id="@+id/saveButton"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:background="@color/transparent"
android:text="@string/save"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"/>
</LinearLayout>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/high_scores"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_gravity="center"
android:layout_marginBottom="32dp"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/highScoresList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginBottom="16dp"/>
<Button
android:id="@+id/backButton"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@color/transparent"
android:text="@string/back"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"/>
</LinearLayout>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/rankText"
android:layout_width="40dp"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"
android:gravity="start"/>
<TextView
android:id="@+id/nameText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"
android:layout_marginStart="16dp"/>
<TextView
android:id="@+id/scoreText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="18sp"
android:fontFamily="monospace"
android:layout_marginStart="16dp"/>
</LinearLayout>

View file

@ -16,4 +16,8 @@
<string name="sound_on">Sound: On</string>
<string name="sound_off">Sound: Off</string>
<string name="toggle_music">Toggle music</string>
<string name="high_scores">High Scores</string>
<string name="new_high_score">New High Score!</string>
<string name="save">Save</string>
<string name="back">Back</string>
</resources>

View file

@ -21,6 +21,3 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Set Java Home to Java 17
org.gradle.java.home=/opt/homebrew/Cellar/openjdk@17/17.0.14/libexec/openjdk.jdk/Contents/Home