From b068de76f53276034755a6b8d7b47fd97b248f35 Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Thu, 27 Mar 2025 00:27:44 -0400 Subject: [PATCH] Add high score system with persistent storage using SharedPreferences and Gson --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 10 + .../com/mintris/HighScoreEntryActivity.kt | 39 +++ .../java/com/mintris/HighScoresActivity.kt | 45 ++++ app/src/main/java/com/mintris/MainActivity.kt | 29 ++ .../main/java/com/mintris/game/GameView.kt | 21 +- .../main/java/com/mintris/model/GameBoard.kt | 251 ++++++++---------- .../main/java/com/mintris/model/HighScore.kt | 8 + .../com/mintris/model/HighScoreAdapter.kt | 42 +++ .../com/mintris/model/HighScoreManager.kt | 47 ++++ .../res/drawable/edit_text_background.xml | 9 + app/src/main/res/layout/activity_main.xml | 10 + app/src/main/res/layout/high_score_entry.xml | 52 ++++ app/src/main/res/layout/high_scores.xml | 38 +++ app/src/main/res/layout/item_high_score.xml | 37 +++ app/src/main/res/values/strings.xml | 4 + gradle.properties | 5 +- 17 files changed, 480 insertions(+), 168 deletions(-) create mode 100644 app/src/main/java/com/mintris/HighScoreEntryActivity.kt create mode 100644 app/src/main/java/com/mintris/HighScoresActivity.kt create mode 100644 app/src/main/java/com/mintris/model/HighScore.kt create mode 100644 app/src/main/java/com/mintris/model/HighScoreAdapter.kt create mode 100644 app/src/main/java/com/mintris/model/HighScoreManager.kt create mode 100644 app/src/main/res/drawable/edit_text_background.xml create mode 100644 app/src/main/res/layout/high_score_entry.xml create mode 100644 app/src/main/res/layout/high_scores.xml create mode 100644 app/src/main/res/layout/item_high_score.xml diff --git a/app/build.gradle b/app/build.gradle index c36d139..da55975 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 69eb6bc..bc5761a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,5 +20,15 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/mintris/HighScoreEntryActivity.kt b/app/src/main/java/com/mintris/HighScoreEntryActivity.kt new file mode 100644 index 0000000..ef2e13a --- /dev/null +++ b/app/src/main/java/com/mintris/HighScoreEntryActivity.kt @@ -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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/HighScoresActivity.kt b/app/src/main/java/com/mintris/HighScoresActivity.kt new file mode 100644 index 0000000..da6dd9c --- /dev/null +++ b/app/src/main/java/com/mintris/HighScoresActivity.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/MainActivity.kt b/app/src/main/java/com/mintris/MainActivity.kt index 5d0c01b..205484d 100644 --- a/app/src/main/java/com/mintris/MainActivity.kt +++ b/app/src/main/java/com/mintris/MainActivity.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/GameView.kt b/app/src/main/java/com/mintris/game/GameView.kt index e82baaf..388c45b 100644 --- a/app/src/main/java/com/mintris/game/GameView.kt +++ b/app/src/main/java/com/mintris/game/GameView.kt @@ -206,25 +206,8 @@ 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) - } + // 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) { diff --git a/app/src/main/java/com/mintris/model/GameBoard.kt b/app/src/main/java/com/mintris/model/GameBoard.kt index 5302ba2..a8db083 100644 --- a/app/src/main/java/com/mintris/model/GameBoard.kt +++ b/app/src/main/java/com/mintris/model/GameBoard.kt @@ -265,174 +265,135 @@ 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) { - spawnPiece() - } + // 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) + var shiftAmount = 0 + var y = height - 1 + + while (y >= 0) { + if (grid[y].all { it }) { + // Line is full, increment shift amount + shiftAmount++ + } else if (shiftAmount > 0) { + // Shift this row down by shiftAmount + System.arraycopy(grid[y], 0, grid[y + shiftAmount], 0, width) } + y-- } - // Sort lines from bottom to top for proper clearing - if (linesToClear.isNotEmpty()) { - linesToClear.sortDescending() - isLineClearAnimationInProgress = true + // Clear top rows + for (y in 0 until shiftAmount) { + java.util.Arrays.fill(grid[y], false) + } + + // If lines were cleared, calculate score in background + if (shiftAmount > 0) { + Thread { + calculateScore(shiftAmount) + }.start() } } /** - * Prepare for line clearing animation + * Calculate score for cleared lines */ - fun finishLineClearingEffect() { - if (linesToClear.isNotEmpty() && !isLineClearAnimationInProgress) { - clearLinesFromGrid() + private fun calculateScore(clearedLines: Int) { + // Pre-calculated score multipliers for better performance + val baseScore = when (clearedLines) { + 1 -> 40 + 2 -> 100 + 3 -> 300 + 4 -> 1200 + else -> 0 } - } - - /** - * 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 - - // Calculate how much to shift each row - for (y in height - 1 downTo 0) { - if (y in linesToClear) { - shiftAmount++ - } else if (shiftAmount > 0) { - rowMoves[y] = y + shiftAmount - } + + // Check for perfect clear (no blocks left) + val isPerfectClear = !grid.any { row -> row.any { it } } + + // Check for all clear (no blocks in playfield) + val isAllClear = !grid.any { row -> row.any { it } } && + currentPiece == null && + nextPiece == null + + // Calculate combo multiplier + val comboMultiplier = if (combo > 0) { + when (combo) { + 1 -> 1.0 + 2 -> 1.5 + 3 -> 2.0 + 4 -> 2.5 + else -> 3.0 } - - // 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) - } + } else 1.0 + + // Calculate back-to-back Tetris bonus + val backToBackMultiplier = if (clearedLines == 4 && lastClearWasTetris) 1.5 else 1.0 + + // Calculate perfect clear bonus + val perfectClearMultiplier = if (isPerfectClear) { + when (clearedLines) { + 1 -> 2.0 + 2 -> 3.0 + 3 -> 4.0 + 4 -> 5.0 + else -> 1.0 } - - // Clear top rows (faster than creating a new array) - for (y in 0 until clearedLines) { - java.util.Arrays.fill(grid[y], false) + } else 1.0 + + // Calculate all clear bonus + val allClearMultiplier = if (isAllClear) 2.0 else 1.0 + + // Calculate T-Spin bonus + val tSpinMultiplier = if (isTSpin()) { + when (clearedLines) { + 1 -> 2.0 + 2 -> 4.0 + 3 -> 6.0 + else -> 1.0 } - - // Calculate base score (NES scoring system) - val baseScore = when (clearedLines) { - 1 -> 40 - 2 -> 100 - 3 -> 300 - 4 -> 1200 // Tetris - else -> 0 - } - - // Check for perfect clear (no blocks left) - val isPerfectClear = !grid.any { row -> row.any { it } } - - // Check for all clear (no blocks in playfield) - val isAllClear = !grid.any { row -> row.any { it } } && - currentPiece == null && - nextPiece == null - - // Calculate combo multiplier - val comboMultiplier = if (combo > 0) { - when (combo) { - 1 -> 1.0 - 2 -> 1.5 - 3 -> 2.0 - 4 -> 2.5 - else -> 3.0 - } - } else 1.0 - - // Calculate back-to-back Tetris bonus - val backToBackMultiplier = if (clearedLines == 4 && lastClearWasTetris) 1.5 else 1.0 - - // Calculate perfect clear bonus - val perfectClearMultiplier = if (isPerfectClear) { - when (clearedLines) { - 1 -> 2.0 - 2 -> 3.0 - 3 -> 4.0 - 4 -> 5.0 - else -> 1.0 - } - } else 1.0 - - // Calculate all clear bonus - val allClearMultiplier = if (isAllClear) 2.0 else 1.0 - - // Calculate T-Spin bonus - val tSpinMultiplier = if (isTSpin()) { - when (clearedLines) { - 1 -> 2.0 - 2 -> 4.0 - 3 -> 6.0 - else -> 1.0 - } - } else 1.0 - - // Calculate final score with all multipliers - val finalScore = (baseScore * level * comboMultiplier * - backToBackMultiplier * perfectClearMultiplier * - allClearMultiplier * tSpinMultiplier).toInt() - + } else 1.0 + + // Calculate final score with all multipliers + val finalScore = (baseScore * level * comboMultiplier * + backToBackMultiplier * perfectClearMultiplier * + allClearMultiplier * tSpinMultiplier).toInt() + + // Update score on main thread + Thread { score += finalScore - - // Update combo counter - if (clearedLines > 0) { - combo++ - } else { - combo = 0 - } - - // Update line clear state - lastClearWasTetris = clearedLines == 4 - lastClearWasPerfect = isPerfectClear - lastClearWasAllClear = isAllClear - - // Update lines cleared and level - lines += clearedLines - level = (lines / 10) + 1 - - // 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() + }.start() + + // Update combo counter + if (clearedLines > 0) { + combo++ + } else { + combo = 0 } + + // Update line clear state + lastClearWasTetris = clearedLines == 4 + lastClearWasPerfect = isPerfectClear + lastClearWasAllClear = isAllClear + + // Update lines cleared and level + lines += clearedLines + level = (lines / 10) + 1 + + // Update game speed based on level (NES formula) + dropInterval = (1000 * Math.pow(0.8, (level - 1).toDouble())).toLong() } /** diff --git a/app/src/main/java/com/mintris/model/HighScore.kt b/app/src/main/java/com/mintris/model/HighScore.kt new file mode 100644 index 0000000..b4b6295 --- /dev/null +++ b/app/src/main/java/com/mintris/model/HighScore.kt @@ -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() +) \ No newline at end of file diff --git a/app/src/main/java/com/mintris/model/HighScoreAdapter.kt b/app/src/main/java/com/mintris/model/HighScoreAdapter.kt new file mode 100644 index 0000000..1f86531 --- /dev/null +++ b/app/src/main/java/com/mintris/model/HighScoreAdapter.kt @@ -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() { + private var highScores: List = emptyList() + + fun updateHighScores(newHighScores: List) { + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/model/HighScoreManager.kt b/app/src/main/java/com/mintris/model/HighScoreManager.kt new file mode 100644 index 0000000..6fd7df9 --- /dev/null +++ b/app/src/main/java/com/mintris/model/HighScoreManager.kt @@ -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>() {}.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 { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_text_background.xml b/app/src/main/res/drawable/edit_text_background.xml new file mode 100644 index 0000000..aaeb099 --- /dev/null +++ b/app/src/main/res/drawable/edit_text_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index f18eabe..d83c8aa 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -190,6 +190,16 @@ android:text="@string/restart" android:textColor="@color/white" android:textSize="18sp" /> + +