Refactor: Introduce ViewModel for score/level, fix game over flow

This commit is contained in:
cmclark00 2025-04-01 14:39:27 -04:00
parent 5952cac760
commit 0a5bf6bb7e
5 changed files with 151 additions and 168 deletions

View file

@ -19,7 +19,7 @@ android {
buildTypes {
release {
minifyEnabled false
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
@ -47,6 +47,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
implementation "androidx.activity:activity-ktx:1.9.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'

View file

@ -3,7 +3,6 @@
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Add permission to handle system gestures if needed on some devices -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application
android:allowBackup="true"

View file

@ -10,6 +10,8 @@ import android.os.VibratorManager
import android.view.View
import android.view.HapticFeedbackConstants
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.viewModels // Added import
import androidx.lifecycle.Observer // Added import
import com.pixelmintdrop.databinding.ActivityMainBinding
import com.pixelmintdrop.game.GameHaptics
import com.pixelmintdrop.game.GameView
@ -50,6 +52,9 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import android.view.ViewGroup
import androidx.core.view.updatePadding
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
class MainActivity : AppCompatActivity(),
GamepadController.GamepadConnectionListener,
@ -60,6 +65,9 @@ class MainActivity : AppCompatActivity(),
private const val TAG = "MainActivity"
}
// ViewModel
private val viewModel: MainActivityViewModel by viewModels() // Added ViewModel
// UI components
private lateinit var binding: ActivityMainBinding
private lateinit var gameView: GameView
@ -78,8 +86,6 @@ class MainActivity : AppCompatActivity(),
// Game state
private var isSoundEnabled = true
private var isMusicEnabled = true
private var currentScore = 0L
private var currentLevel = 1
private var piecesPlaced = 0
private var gameStartTime = 0L
private var selectedLevel = 1
@ -215,6 +221,17 @@ class MainActivity : AppCompatActivity(),
blockSkinSelector = binding.customizationBlockSkinSelector!!
pauseMenuScrollView = binding.pauseMenuScrollView
// Observe ViewModel LiveData
viewModel.currentScore.observe(this, Observer { newScore ->
// Use actual ID from layout - display only the number
binding.scoreText.text = newScore.toString()
})
viewModel.currentLevel.observe(this, Observer { newLevel ->
// Use actual ID from layout - display only the number
binding.currentLevelText.text = newLevel.toString()
})
// Load random mode setting
isRandomModeEnabled = getSharedPreferences("com.com.pixelmintgames.pixelmintdrop.preferences", Context.MODE_PRIVATE)
.getBoolean("random_mode_enabled", false)
@ -348,28 +365,17 @@ class MainActivity : AppCompatActivity(),
}
gameView.onGameOver = { finalScore ->
// Pause music on game over
// Start animation & pause music
gameView.startGameOverAnimation()
gameMusic.pause()
// Update high scores
// Calculate final stats, XP, and high score
val timePlayedMs = System.currentTimeMillis() - gameStartTime
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val currentDate = dateFormat.format(Date())
// Track if this is a high score
val isHighScore = highScoreManager.isHighScore(finalScore)
// Show game over screen
showGameOver(finalScore)
// Save player stats to track game history
statsManager.updateSessionStats(finalScore, gameBoard.lines, piecesPlaced, timePlayedMs, currentLevel)
// Handle progression - XP earned, potential level up
statsManager.updateSessionStats(finalScore, gameBoard.lines, piecesPlaced, timePlayedMs, viewModel.currentLevel.value ?: 1)
val xpGained = progressionManager.calculateGameXP(
score = finalScore,
linesCleared = gameBoard.lines,
level = currentLevel,
level = viewModel.currentLevel.value ?: 1,
gameTime = timePlayedMs,
tSpins = statsManager.getSessionTSpins(),
combos = statsManager.getSessionMaxCombo(),
@ -377,13 +383,22 @@ class MainActivity : AppCompatActivity(),
perfectClearCount = statsManager.getSessionPerfectClears()
)
val newRewards = progressionManager.addXP(xpGained)
val newHighScore = highScoreManager.isHighScore(finalScore)
statsManager.endSession() // End session after calculations
// Show progression screen if player earned XP
if (xpGained > 0) {
// Delay showing progression screen for a moment
Log.d(TAG, "Game Over. Score: $finalScore, Level: ${viewModel.currentLevel.value}, Lines: ${gameBoard.lines}, Start Level: $selectedLevel, New High Score: $newHighScore, XP Gained: $xpGained")
// Show appropriate screen: Progression or Game Over directly
if (xpGained > 0 || newHighScore) {
// Delay showing progression slightly to let animation play
Handler(Looper.getMainLooper()).postDelayed({
showProgressionScreen(xpGained, newRewards)
}, 2000)
showProgressionScreen(xpGained, newRewards, newHighScore, finalScore)
}, 1500) // Delay can be adjusted
} else {
// No XP, no high score -> show game over screen directly after animation
Handler(Looper.getMainLooper()).postDelayed({
showGameOverScreenDirectly(finalScore)
}, 1500) // Delay to match progression path
}
}
@ -515,127 +530,54 @@ class MainActivity : AppCompatActivity(),
binding.linesText.text = lines.toString()
binding.comboText.text = gameBoard.getCombo().toString()
// Update current level for stats
currentLevel = level
// Force redraw of next piece preview
binding.nextPieceView.invalidate()
}
/**
* Show game over screen
* Shows the final game over screen with stats.
*/
private fun showGameOver(score: Int) {
Log.d("MainActivity", "Showing game over screen with score: $score")
val gameTime = System.currentTimeMillis() - gameStartTime
// Hide all game UI elements
private fun showGameOverScreenDirectly(score: Int) {
Log.d(TAG, "Showing final game over screen with score: $score")
// Ensure game UI is hidden
binding.gameControlsContainer.visibility = View.GONE
binding.holdPieceView.visibility = View.GONE
binding.nextPieceView.visibility = View.GONE
binding.pauseButton.visibility = View.GONE
// Hide landscape panels if they exist
binding.leftControlsPanel?.visibility = View.GONE
binding.rightControlsPanel?.visibility = View.GONE
progressionScreen.visibility = View.GONE // Ensure progression is hidden
// Update session stats
statsManager.updateSessionStats(
score = score,
lines = gameBoard.lines,
pieces = piecesPlaced,
time = gameTime,
level = currentLevel
)
// Calculate XP earned
val xpGained = progressionManager.calculateGameXP(
score = statsManager.getSessionScore(),
linesCleared = statsManager.getSessionLines(),
level = currentLevel,
gameTime = gameTime,
tSpins = statsManager.getSessionTSpins(),
combos = statsManager.getSessionMaxCombo(),
quadCount = statsManager.getSessionQuads(),
perfectClearCount = statsManager.getSessionPerfectClears()
)
// Add XP and check for rewards
val newRewards = progressionManager.addXP(xpGained)
// End session and save stats
statsManager.endSession()
// Update session stats display
// Update session stats display in the gameOverContainer
val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
timeFormat.timeZone = TimeZone.getTimeZone("UTC")
val gameTime = System.currentTimeMillis() - gameStartTime // Recalculate or pass from onGameOver?
// Let's recalculate for simplicity here.
binding.sessionScoreText.text = getString(R.string.session_score, score)
binding.sessionLinesText.text = getString(R.string.session_lines, gameBoard.lines)
binding.sessionPiecesText.text = getString(R.string.session_pieces, piecesPlaced)
binding.sessionTimeText.text = getString(R.string.session_time, timeFormat.format(gameTime))
binding.sessionLevelText.text = getString(R.string.session_level, currentLevel)
binding.sessionLevelText.text = getString(R.string.session_level, viewModel.currentLevel.value ?: 1)
// Update session line clear stats
binding.sessionSinglesText.text = getString(R.string.singles, statsManager.getSessionSingles())
binding.sessionDoublesText.text = getString(R.string.doubles, statsManager.getSessionDoubles())
binding.sessionTriplesText.text = getString(R.string.triples, statsManager.getSessionTriples())
binding.sessionQuadsText.text = getString(R.string.quads, statsManager.getSessionQuads())
// Flag to track if high score screen will be shown
var showingHighScore = false
// Make the container visible
binding.gameOverContainer.visibility = View.VISIBLE
// Play game over sound and trigger animation
if (isSoundEnabled) {
// Play game over sound if not already played by progression
if (isSoundEnabled && progressionScreen.visibility != View.VISIBLE) {
gameMusic.playGameOver()
}
// First trigger the animation in the game view
Log.d("MainActivity", "Triggering game over animation")
gameView.startGameOverAnimation()
// Wait a moment before showing progression screen to let animation be visible
Handler(Looper.getMainLooper()).postDelayed({
// Show progression screen first with XP animation
showProgressionScreen(xpGained, newRewards)
// Override the continue button behavior if high score needs to be shown
val originalOnContinue = progressionScreen.onContinue
progressionScreen.onContinue = {
// If this is a high score, show high score entry screen
if (highScoreManager.isHighScore(score)) {
showingHighScore = true
showHighScoreEntry(score)
} else {
// Just show game over screen normally
progressionScreen.visibility = View.GONE
binding.gameOverContainer.visibility = View.VISIBLE
// Keep all game UI elements hidden
binding.gameControlsContainer.visibility = View.GONE
binding.holdPieceView.visibility = View.GONE
binding.nextPieceView.visibility = View.GONE
binding.pauseButton.visibility = View.GONE
binding.leftControlsPanel?.visibility = View.GONE
binding.rightControlsPanel?.visibility = View.GONE
// Get all themes with "Theme" in their name
val themeRewards = progressionManager.getUnlockedThemes().filter {
it.contains("Theme", ignoreCase = true)
}
// Update theme selector if new themes were unlocked
if (themeRewards.isNotEmpty()) {
updateThemeSelector()
}
}
}
}, 2000) // Increased from 1000ms (1 second) to 2000ms (2 seconds)
// Vibrate to indicate game over
// Vibrate if not already vibrated by progression
if (progressionScreen.visibility != View.VISIBLE) {
vibrate(VibrationEffect.EFFECT_DOUBLE_CLICK)
}
}
/**
* Show high score entry screen
@ -643,7 +585,7 @@ class MainActivity : AppCompatActivity(),
private fun showHighScoreEntry(score: Int) {
val intent = Intent(this, HighScoreEntryActivity::class.java).apply {
putExtra("score", score)
putExtra("level", currentLevel)
putExtra("level", viewModel.currentLevel.value ?: 1) // Read from ViewModel
}
// Use the launcher instead of startActivity
highScoreEntryLauncher.launch(intent)
@ -930,30 +872,23 @@ class MainActivity : AppCompatActivity(),
* Start a new game
*/
private fun startGame() {
// Reset pieces placed counter
Log.d(TAG, "Starting game at level $selectedLevel")
// Reset game state
viewModel.resetGame() // Resets score and level in ViewModel
viewModel.setLevel(selectedLevel) // Set initial level from selection
piecesPlaced = 0
// Set initial game state
currentScore = 0
currentLevel = selectedLevel
lastLines = 0 // Reset lastLines to 0
lastLinesGroup = 0 // Reset lastLinesGroup to 0
lastRandomLevel = 0 // Reset lastRandomLevel to 0
gameStartTime = System.currentTimeMillis()
// Update UI to show initial values
binding.scoreText.text = "$currentScore"
binding.currentLevelText.text = "$currentLevel"
binding.linesText.text = "0"
binding.comboText.text = "0"
lastLines = 0
lastLinesGroup = 0
lastRandomLevel = 0
// Observers will update scoreText and currentLevelText
// Reset game view and game board
gameBoard.reset()
gameView.reset()
// Ensure block skin is properly set (helps with random mode)
// Ensure block skin is properly set
val selectedSkin = progressionManager.getSelectedBlockSkin()
Log.d("RandomMode", "Game start: Setting block skin to $selectedSkin")
gameView.setBlockSkin(selectedSkin)
// Update selectors to refresh UI state
@ -963,8 +898,8 @@ class MainActivity : AppCompatActivity(),
progressionManager.getPlayerLevel()
)
// Show game elements
gameView.visibility = View.VISIBLE
// Ensure game UI elements are visible and others hidden
binding.gameView.visibility = View.VISIBLE
binding.gameControlsContainer.visibility = View.VISIBLE
binding.holdPieceView.visibility = View.VISIBLE
binding.nextPieceView.visibility = View.VISIBLE
@ -974,7 +909,7 @@ class MainActivity : AppCompatActivity(),
titleScreen.visibility = View.GONE
progressionScreen.visibility = View.GONE
// Show game UI elements in landscape mode
// Show landscape specific controls if needed
if (resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) {
binding.leftControlsPanel?.visibility = View.VISIBLE
binding.rightControlsPanel?.visibility = View.VISIBLE
@ -982,32 +917,22 @@ class MainActivity : AppCompatActivity(),
// Configure callbacks
gameView.onGameStateChanged = { score, level, lines ->
// We'll adapt updateGameStateUI later to use ViewModel
updateGameStateUI(score, level, lines)
}
gameView.onGameOver = { finalScore ->
// Pause music on game over
// Start animation & pause music
gameView.startGameOverAnimation()
gameMusic.pause()
// Update high scores
// Calculate final stats, XP, and high score
val timePlayedMs = System.currentTimeMillis() - gameStartTime
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val currentDate = dateFormat.format(Date())
// Track if this is a high score
val isHighScore = highScoreManager.isHighScore(finalScore)
// Show game over screen
showGameOver(finalScore)
// Save player stats to track game history
statsManager.updateSessionStats(finalScore, gameBoard.lines, piecesPlaced, timePlayedMs, currentLevel)
// Handle progression - XP earned, potential level up
statsManager.updateSessionStats(finalScore, gameBoard.lines, piecesPlaced, timePlayedMs, viewModel.currentLevel.value ?: 1)
val xpGained = progressionManager.calculateGameXP(
score = finalScore,
linesCleared = gameBoard.lines,
level = currentLevel,
level = viewModel.currentLevel.value ?: 1,
gameTime = timePlayedMs,
tSpins = statsManager.getSessionTSpins(),
combos = statsManager.getSessionMaxCombo(),
@ -1015,13 +940,22 @@ class MainActivity : AppCompatActivity(),
perfectClearCount = statsManager.getSessionPerfectClears()
)
val newRewards = progressionManager.addXP(xpGained)
val newHighScore = highScoreManager.isHighScore(finalScore)
statsManager.endSession() // End session after calculations
// Show progression screen if player earned XP
if (xpGained > 0) {
// Delay showing progression screen for a moment
Log.d(TAG, "Game Over. Score: $finalScore, Level: ${viewModel.currentLevel.value}, Lines: ${gameBoard.lines}, Start Level: $selectedLevel, New High Score: $newHighScore, XP Gained: $xpGained")
// Show appropriate screen: Progression or Game Over directly
if (xpGained > 0 || newHighScore) {
// Delay showing progression slightly to let animation play
Handler(Looper.getMainLooper()).postDelayed({
showProgressionScreen(xpGained, newRewards)
}, 2000)
showProgressionScreen(xpGained, newRewards, newHighScore, finalScore)
}, 1500) // Delay can be adjusted
} else {
// No XP, no high score -> show game over screen directly after animation
Handler(Looper.getMainLooper()).postDelayed({
showGameOverScreenDirectly(finalScore)
}, 1500) // Delay to match progression path
}
}
@ -1423,11 +1357,11 @@ class MainActivity : AppCompatActivity(),
return super.onGenericMotionEvent(event)
}
private fun showProgressionScreen(xpGained: Long, newRewards: List<String>) {
private fun showProgressionScreen(xpGained: Long, newRewards: List<String>, isNewHighScore: Boolean, finalScore: Int) {
// Apply theme before showing the screen
progressionScreen.applyTheme(currentTheme)
// Hide all game UI elements
// Hide game/other UI elements
binding.gameControlsContainer.visibility = View.GONE
binding.holdPieceView.visibility = View.GONE
binding.nextPieceView.visibility = View.GONE
@ -1443,6 +1377,16 @@ class MainActivity : AppCompatActivity(),
// Display progression data
progressionScreen.showProgress(progressionManager, xpGained, newRewards, currentTheme)
// Set up the continue action
progressionScreen.onContinue = {
if (isNewHighScore) {
showHighScoreEntry(finalScore)
} else {
// No high score, just show the final game over screen
showGameOverScreenDirectly(finalScore)
}
}
}
/**
@ -2045,11 +1989,10 @@ class MainActivity : AppCompatActivity(),
}
private fun updateGameStateUI(score: Int, level: Int, lines: Int) {
currentScore = score.toLong()
currentLevel = level
viewModel.setScore(score.toLong()) // Use ViewModel setter
viewModel.setLevel(level) // Use ViewModel setter
binding.scoreText.text = "$score"
binding.currentLevelText.text = "$level"
// Update other UI elements not handled by score/level observers
binding.linesText.text = "$lines"
binding.comboText.text = gameBoard.getCombo().toString()

View file

@ -0,0 +1,40 @@
package com.pixelmintdrop
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class MainActivityViewModel : ViewModel() {
// Private MutableLiveData for internal updates
private val _currentScore = MutableLiveData<Long>(0L)
private val _currentLevel = MutableLiveData<Int>(1)
// Public LiveData for observation by the Activity
val currentScore: LiveData<Long> = _currentScore
val currentLevel: LiveData<Int> = _currentLevel
// Example function to update the score (logic would be moved here)
fun incrementScore(points: Long) {
_currentScore.value = (_currentScore.value ?: 0L) + points
// Potentially add logic here to check for level up based on score
}
// Function to set the score directly
fun setScore(score: Long) {
_currentScore.value = score
}
// Example function to update the level
fun setLevel(level: Int) {
_currentLevel.value = level
}
fun resetGame() {
_currentScore.value = 0L
_currentLevel.value = 1
// Reset other game state within the ViewModel as needed
}
// Add other state variables and logic related to game state here
}

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME