mirror of
https://github.com/cmclark00/mintris.git
synced 2025-05-18 17:35:21 +01:00
Fix: Correct namespace and applicationId in app build.gradle
This commit is contained in:
parent
5cf8aec02a
commit
5ace9d7fc5
25 changed files with 4 additions and 4 deletions
141
app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt
Normal file
141
app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt
Normal file
|
@ -0,0 +1,141 @@
|
|||
package com.mintris
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.mintris.databinding.HighScoreEntryBinding
|
||||
import com.mintris.model.HighScore
|
||||
import com.mintris.model.HighScoreManager
|
||||
import com.mintris.model.PlayerProgressionManager
|
||||
import android.graphics.Color
|
||||
import android.view.KeyEvent
|
||||
import android.view.InputDevice
|
||||
|
||||
class HighScoreEntryActivity : AppCompatActivity() {
|
||||
private lateinit var binding: HighScoreEntryBinding
|
||||
private lateinit var highScoreManager: HighScoreManager
|
||||
private lateinit var progressionManager: PlayerProgressionManager
|
||||
private var currentTheme = PlayerProgressionManager.THEME_CLASSIC
|
||||
private var score: Int = 0
|
||||
|
||||
// Track if we already saved to prevent double-saving
|
||||
private var hasSaved = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = HighScoreEntryBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
highScoreManager = HighScoreManager(this)
|
||||
progressionManager = PlayerProgressionManager(this)
|
||||
|
||||
// Load and apply theme
|
||||
currentTheme = loadThemePreference()
|
||||
applyTheme(currentTheme)
|
||||
|
||||
score = intent.getIntExtra("score", 0)
|
||||
binding.scoreText.text = "Score: $score"
|
||||
|
||||
binding.saveButton.setOnClickListener {
|
||||
saveScore()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveScore() {
|
||||
// Only allow saving once
|
||||
if (!hasSaved) {
|
||||
val name = binding.nameInput.text.toString().trim()
|
||||
if (name.isNotEmpty()) {
|
||||
hasSaved = true
|
||||
val highScore = HighScore(name, score, 1)
|
||||
highScoreManager.addHighScore(highScore)
|
||||
|
||||
// Set result and finish
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override onKeyDown to handle gamepad buttons
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
// Check if it's a gamepad input
|
||||
if (event != null && isGamepadDevice(event.device)) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A,
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
// A button saves the score
|
||||
saveScore()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||
// B button cancels
|
||||
setResult(Activity.RESULT_CANCELED)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
// Helper function to check if the device is a gamepad
|
||||
private fun isGamepadDevice(device: InputDevice?): Boolean {
|
||||
if (device == null) return false
|
||||
val sources = device.sources
|
||||
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
|
||||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
// Prevent accidental back button press from causing issues
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
// If they haven't saved yet, consider it a cancel
|
||||
if (!hasSaved) {
|
||||
setResult(Activity.RESULT_CANCELED)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun loadThemePreference(): String {
|
||||
return progressionManager.getSelectedTheme()
|
||||
}
|
||||
|
||||
private fun applyTheme(themeId: String) {
|
||||
// Set background color
|
||||
val backgroundColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
|
||||
else -> Color.BLACK
|
||||
}
|
||||
binding.root.setBackgroundColor(backgroundColor)
|
||||
|
||||
// Set text color
|
||||
val textColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#FF00FF")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.LTGRAY
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#FF5A5F")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#66FCF1")
|
||||
else -> Color.WHITE
|
||||
}
|
||||
|
||||
// Apply text color to score and input
|
||||
binding.scoreText.setTextColor(textColor)
|
||||
binding.nameInput.setTextColor(textColor)
|
||||
binding.nameInput.setHintTextColor(Color.argb(128, Color.red(textColor), Color.green(textColor), Color.blue(textColor)))
|
||||
|
||||
// Apply theme to submit button
|
||||
binding.saveButton.setTextColor(textColor)
|
||||
}
|
||||
}
|
143
app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt
Normal file
143
app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt
Normal file
|
@ -0,0 +1,143 @@
|
|||
package com.mintris
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.mintris.databinding.HighScoresBinding
|
||||
import com.mintris.model.HighScoreAdapter
|
||||
import com.mintris.model.HighScoreManager
|
||||
import com.mintris.model.PlayerProgressionManager
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.InputDevice
|
||||
|
||||
class HighScoresActivity : AppCompatActivity() {
|
||||
private lateinit var binding: HighScoresBinding
|
||||
private lateinit var highScoreManager: HighScoreManager
|
||||
private lateinit var highScoreAdapter: HighScoreAdapter
|
||||
private lateinit var progressionManager: PlayerProgressionManager
|
||||
private var currentTheme = PlayerProgressionManager.THEME_CLASSIC
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
try {
|
||||
binding = HighScoresBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
highScoreManager = HighScoreManager(this)
|
||||
progressionManager = PlayerProgressionManager(this)
|
||||
|
||||
// Load and apply theme
|
||||
currentTheme = loadThemePreference()
|
||||
|
||||
// Initialize adapter before applying theme
|
||||
highScoreAdapter = HighScoreAdapter()
|
||||
|
||||
// Set up RecyclerView
|
||||
binding.highScoresList.layoutManager = LinearLayoutManager(this)
|
||||
binding.highScoresList.adapter = highScoreAdapter
|
||||
|
||||
// Now apply theme to UI
|
||||
applyTheme(currentTheme)
|
||||
|
||||
// Load high scores
|
||||
updateHighScores()
|
||||
|
||||
// Set up back button
|
||||
binding.backButton.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("HighScoresActivity", "Error in onCreate", e)
|
||||
// Show an error message if necessary, or finish gracefully
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadThemePreference(): String {
|
||||
return progressionManager.getSelectedTheme()
|
||||
}
|
||||
|
||||
private fun applyTheme(themeId: String) {
|
||||
try {
|
||||
// Set background color
|
||||
val backgroundColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
|
||||
else -> Color.BLACK
|
||||
}
|
||||
binding.root.setBackgroundColor(backgroundColor)
|
||||
|
||||
// Set text color
|
||||
val textColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#FF00FF")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.LTGRAY
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#FF5A5F")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#66FCF1")
|
||||
else -> Color.WHITE
|
||||
}
|
||||
|
||||
// Apply theme to back button
|
||||
binding.backButton.setTextColor(textColor)
|
||||
|
||||
// Update adapter theme
|
||||
highScoreAdapter.applyTheme(themeId)
|
||||
} catch (e: Exception) {
|
||||
Log.e("HighScoresActivity", "Error applying theme: $themeId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHighScores() {
|
||||
try {
|
||||
val scores = highScoreManager.getHighScores()
|
||||
highScoreAdapter.updateHighScores(scores)
|
||||
} catch (e: Exception) {
|
||||
Log.e("HighScoresActivity", "Error updating high scores", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
try {
|
||||
updateHighScores()
|
||||
} catch (e: Exception) {
|
||||
Log.e("HighScoresActivity", "Error in onResume", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle gamepad buttons
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
// Check if it's a gamepad input
|
||||
if (event != null && isGamepadDevice(event.device)) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_B,
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
// B button or Back button returns to previous screen
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
// Helper function to check if the device is a gamepad
|
||||
private fun isGamepadDevice(device: InputDevice?): Boolean {
|
||||
if (device == null) return false
|
||||
val sources = device.sources
|
||||
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
|
||||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
}
|
2028
app/src/main/java/com/pixelmintdrop/MainActivity.kt
Normal file
2028
app/src/main/java/com/pixelmintdrop/MainActivity.kt
Normal file
File diff suppressed because it is too large
Load diff
128
app/src/main/java/com/pixelmintdrop/StatsActivity.kt
Normal file
128
app/src/main/java/com/pixelmintdrop/StatsActivity.kt
Normal file
|
@ -0,0 +1,128 @@
|
|||
package com.mintris
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.mintris.databinding.ActivityStatsBinding
|
||||
import com.mintris.model.StatsManager
|
||||
import com.mintris.model.PlayerProgressionManager
|
||||
import android.graphics.Color
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class StatsActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityStatsBinding
|
||||
private lateinit var statsManager: StatsManager
|
||||
private lateinit var progressionManager: PlayerProgressionManager
|
||||
private var currentTheme = PlayerProgressionManager.THEME_CLASSIC
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityStatsBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
statsManager = StatsManager(this)
|
||||
progressionManager = PlayerProgressionManager(this)
|
||||
|
||||
// Load and apply theme
|
||||
currentTheme = loadThemePreference()
|
||||
applyTheme(currentTheme)
|
||||
|
||||
// Set up back button
|
||||
binding.backButton.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
// Set up reset stats button
|
||||
binding.resetStatsButton.setOnClickListener {
|
||||
showResetConfirmationDialog()
|
||||
}
|
||||
|
||||
updateStats()
|
||||
}
|
||||
|
||||
private fun loadThemePreference(): String {
|
||||
return progressionManager.getSelectedTheme()
|
||||
}
|
||||
|
||||
private fun applyTheme(themeId: String) {
|
||||
// Set background color
|
||||
val backgroundColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
|
||||
else -> Color.BLACK
|
||||
}
|
||||
binding.root.setBackgroundColor(backgroundColor)
|
||||
|
||||
// Set text color
|
||||
val textColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#FF00FF")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.LTGRAY
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#FF5A5F")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#66FCF1")
|
||||
else -> Color.WHITE
|
||||
}
|
||||
|
||||
// Apply text color to all TextViews
|
||||
binding.totalGamesText.setTextColor(textColor)
|
||||
binding.totalScoreText.setTextColor(textColor)
|
||||
binding.totalLinesText.setTextColor(textColor)
|
||||
binding.totalPiecesText.setTextColor(textColor)
|
||||
binding.totalTimeText.setTextColor(textColor)
|
||||
binding.totalSinglesText.setTextColor(textColor)
|
||||
binding.totalDoublesText.setTextColor(textColor)
|
||||
binding.totalTriplesText.setTextColor(textColor)
|
||||
binding.totalTetrisesText.setTextColor(textColor)
|
||||
binding.maxLevelText.setTextColor(textColor)
|
||||
binding.maxScoreText.setTextColor(textColor)
|
||||
binding.maxLinesText.setTextColor(textColor)
|
||||
|
||||
// Apply theme to buttons
|
||||
binding.backButton.setTextColor(textColor)
|
||||
binding.resetStatsButton.setTextColor(textColor)
|
||||
}
|
||||
|
||||
private fun showResetConfirmationDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Reset Stats")
|
||||
.setMessage("Are you sure you want to reset all your stats? This cannot be undone.")
|
||||
.setPositiveButton("Reset") { _, _ ->
|
||||
statsManager.resetStats()
|
||||
updateStats()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun updateStats() {
|
||||
// Format time duration
|
||||
val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
timeFormat.timeZone = TimeZone.getTimeZone("UTC")
|
||||
|
||||
// Update lifetime stats
|
||||
binding.totalGamesText.text = getString(R.string.total_games, statsManager.getTotalGames())
|
||||
binding.totalScoreText.text = getString(R.string.total_score, statsManager.getTotalScore())
|
||||
binding.totalLinesText.text = getString(R.string.total_lines, statsManager.getTotalLines())
|
||||
binding.totalPiecesText.text = getString(R.string.total_pieces, statsManager.getTotalPieces())
|
||||
binding.totalTimeText.text = getString(R.string.total_time, timeFormat.format(statsManager.getTotalTime()))
|
||||
|
||||
// Update line clear stats
|
||||
binding.totalSinglesText.text = getString(R.string.singles, statsManager.getTotalSingles())
|
||||
binding.totalDoublesText.text = getString(R.string.doubles, statsManager.getTotalDoubles())
|
||||
binding.totalTriplesText.text = getString(R.string.triples, statsManager.getTotalTriples())
|
||||
binding.totalTetrisesText.text = getString(R.string.tetrises, statsManager.getTotalTetrises())
|
||||
|
||||
// Update best performance stats
|
||||
binding.maxLevelText.text = getString(R.string.max_level, statsManager.getMaxLevel())
|
||||
binding.maxScoreText.text = getString(R.string.max_score, statsManager.getMaxScore())
|
||||
binding.maxLinesText.text = getString(R.string.max_lines, statsManager.getMaxLines())
|
||||
}
|
||||
}
|
19
app/src/main/java/com/pixelmintdrop/ThemeManager.kt
Normal file
19
app/src/main/java/com/pixelmintdrop/ThemeManager.kt
Normal file
|
@ -0,0 +1,19 @@
|
|||
package com.mintris
|
||||
|
||||
import android.graphics.Color
|
||||
|
||||
object ThemeManager {
|
||||
data class ThemeColors(
|
||||
val background: Int,
|
||||
val text: Int,
|
||||
val accent: Int
|
||||
)
|
||||
|
||||
fun getThemeColors(): ThemeColors {
|
||||
return ThemeColors(
|
||||
background = Color.BLACK,
|
||||
text = Color.WHITE,
|
||||
accent = Color.WHITE
|
||||
)
|
||||
}
|
||||
}
|
163
app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt
Normal file
163
app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt
Normal file
|
@ -0,0 +1,163 @@
|
|||
package com.mintris.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaPlayer
|
||||
import android.media.AudioAttributes
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.mintris.R
|
||||
|
||||
class GameMusic(private val context: Context) {
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var gameOverPlayer: MediaPlayer? = null
|
||||
private var isEnabled = true
|
||||
private var isPrepared = false
|
||||
|
||||
init {
|
||||
try {
|
||||
setupMediaPlayer()
|
||||
setupGameOverPlayer()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error initializing: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMediaPlayer() {
|
||||
try {
|
||||
Log.d("GameMusic", "Setting up MediaPlayer")
|
||||
mediaPlayer = MediaPlayer.create(context, R.raw.game_music).apply {
|
||||
isLooping = true
|
||||
setVolume(0.5f, 0.5f)
|
||||
|
||||
// Set audio attributes for better performance
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_GAME)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
isPrepared = true
|
||||
}
|
||||
Log.d("GameMusic", "MediaPlayer setup complete")
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error setting up MediaPlayer", e)
|
||||
mediaPlayer = null
|
||||
isPrepared = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupGameOverPlayer() {
|
||||
try {
|
||||
Log.d("GameMusic", "Setting up GameOver MediaPlayer")
|
||||
gameOverPlayer = MediaPlayer.create(context, R.raw.game_over).apply {
|
||||
setVolume(1.0f, 1.0f) // Increased from 0.7f to 1.0f for maximum volume
|
||||
|
||||
// Set audio attributes for better performance
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_GAME)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("GameMusic", "GameOver MediaPlayer setup complete")
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error setting up GameOver MediaPlayer", e)
|
||||
gameOverPlayer = null
|
||||
}
|
||||
}
|
||||
|
||||
fun playGameOver() {
|
||||
if (isEnabled && gameOverPlayer != null) {
|
||||
try {
|
||||
Log.d("GameMusic", "Playing game over sound")
|
||||
// Temporarily lower background music volume
|
||||
mediaPlayer?.setVolume(0.2f, 0.2f)
|
||||
|
||||
// Play game over sound
|
||||
gameOverPlayer?.start()
|
||||
|
||||
// Restore background music volume after a delay
|
||||
gameOverPlayer?.setOnCompletionListener {
|
||||
mediaPlayer?.setVolume(0.5f, 0.5f)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error playing game over sound: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (isEnabled && mediaPlayer != null && isPrepared) {
|
||||
try {
|
||||
Log.d("GameMusic", "Starting music playback, isEnabled: $isEnabled")
|
||||
mediaPlayer?.start()
|
||||
Log.d("GameMusic", "Music playback started")
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error starting music: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
try {
|
||||
Log.d("GameMusic", "Pausing music playback")
|
||||
if (mediaPlayer?.isPlaying == true) {
|
||||
mediaPlayer?.pause()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error pausing music: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
if (isEnabled && mediaPlayer != null && isPrepared) {
|
||||
try {
|
||||
Log.d("GameMusic", "Resuming music playback")
|
||||
mediaPlayer?.start()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error resuming music: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
try {
|
||||
Log.d("GameMusic", "Stopping music playback")
|
||||
mediaPlayer?.stop()
|
||||
mediaPlayer?.prepare()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error stopping music", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
Log.d("GameMusic", "Setting music enabled: $enabled")
|
||||
isEnabled = enabled
|
||||
|
||||
if (!enabled && mediaPlayer?.isPlaying == true) {
|
||||
pause()
|
||||
} else if (enabled && mediaPlayer != null && isPrepared) {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun isEnabled(): Boolean = isEnabled
|
||||
|
||||
fun release() {
|
||||
try {
|
||||
Log.d("GameMusic", "Releasing MediaPlayer")
|
||||
mediaPlayer?.release()
|
||||
gameOverPlayer?.release()
|
||||
mediaPlayer = null
|
||||
gameOverPlayer = null
|
||||
isPrepared = false
|
||||
} catch (e: Exception) {
|
||||
Log.e("GameMusic", "Error releasing music: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
136
app/src/main/java/com/pixelmintdrop/game/GameHaptics.kt
Normal file
136
app/src/main/java/com/pixelmintdrop/game/GameHaptics.kt
Normal file
|
@ -0,0 +1,136 @@
|
|||
package com.mintris.game
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Handles haptic feedback for game events
|
||||
*/
|
||||
class GameHaptics(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GameHaptics"
|
||||
}
|
||||
|
||||
// Vibrator service
|
||||
private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
|
||||
// Track if gamepad is connected
|
||||
private var isGamepadConnected = false
|
||||
|
||||
// Set gamepad connection state
|
||||
fun setGamepadConnected(connected: Boolean) {
|
||||
isGamepadConnected = connected
|
||||
}
|
||||
|
||||
// Get vibration multiplier based on input method
|
||||
private fun getVibrationMultiplier(): Float {
|
||||
return if (isGamepadConnected) 1.5f else 1.0f
|
||||
}
|
||||
|
||||
// Vibrate for line clear (more intense for more lines)
|
||||
fun vibrateForLineClear(lineCount: Int) {
|
||||
Log.d(TAG, "Attempting to vibrate for $lineCount lines")
|
||||
|
||||
// Only proceed if the device has a vibrator and it's available
|
||||
if (!vibrator.hasVibrator()) return
|
||||
|
||||
// Scale duration and amplitude based on line count and input method
|
||||
val multiplier = getVibrationMultiplier()
|
||||
val duration = when(lineCount) {
|
||||
1 -> (50L * multiplier).toLong() // Single line: short vibration
|
||||
2 -> (80L * multiplier).toLong() // Double line: slightly longer
|
||||
3 -> (120L * multiplier).toLong() // Triple line: even longer
|
||||
4 -> (200L * multiplier).toLong() // Tetris: longest vibration
|
||||
else -> (50L * multiplier).toLong()
|
||||
}
|
||||
|
||||
val amplitude = when(lineCount) {
|
||||
1 -> (80 * multiplier).toInt().coerceAtMost(255) // Single line: mild vibration
|
||||
2 -> (120 * multiplier).toInt().coerceAtMost(255) // Double line: medium vibration
|
||||
3 -> (180 * multiplier).toInt().coerceAtMost(255) // Triple line: strong vibration
|
||||
4 -> 255 // Tetris: maximum vibration
|
||||
else -> (80 * multiplier).toInt().coerceAtMost(255)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Vibration parameters - Duration: ${duration}ms, Amplitude: $amplitude")
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
|
||||
Log.d(TAG, "Vibration triggered successfully")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(duration)
|
||||
Log.w(TAG, "Device does not support vibration effects (Android < 8.0)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error triggering vibration", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun performHapticFeedback(view: View, feedbackType: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
view.performHapticFeedback(feedbackType)
|
||||
}
|
||||
}
|
||||
|
||||
fun vibrateForPieceLock() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val multiplier = getVibrationMultiplier()
|
||||
val duration = (50L * multiplier).toLong()
|
||||
val amplitude = (VibrationEffect.DEFAULT_AMPLITUDE * multiplier).toInt().coerceAtMost(255)
|
||||
val vibrationEffect = VibrationEffect.createOneShot(duration, amplitude)
|
||||
vibrator.vibrate(vibrationEffect)
|
||||
}
|
||||
}
|
||||
|
||||
fun vibrateForPieceMove() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val multiplier = getVibrationMultiplier()
|
||||
val duration = (20L * multiplier).toLong()
|
||||
val amplitude = (VibrationEffect.DEFAULT_AMPLITUDE * 0.3 * multiplier).toInt().coerceAtLeast(1).coerceAtMost(255)
|
||||
val vibrationEffect = VibrationEffect.createOneShot(duration, amplitude)
|
||||
vibrator.vibrate(vibrationEffect)
|
||||
}
|
||||
}
|
||||
|
||||
fun vibrateForGameOver() {
|
||||
Log.d(TAG, "Attempting to vibrate for game over")
|
||||
|
||||
// Only proceed if the device has a vibrator and it's available
|
||||
if (!vibrator.hasVibrator()) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val multiplier = getVibrationMultiplier()
|
||||
val duration = (300L * multiplier).toLong()
|
||||
val amplitude = (VibrationEffect.DEFAULT_AMPLITUDE * multiplier).toInt().coerceAtMost(255)
|
||||
val vibrationEffect = VibrationEffect.createOneShot(duration, amplitude)
|
||||
vibrator.vibrate(vibrationEffect)
|
||||
Log.d(TAG, "Game over vibration triggered successfully")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(300L)
|
||||
Log.w(TAG, "Device does not support vibration effects (Android < 8.0)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error triggering game over vibration", e)
|
||||
}
|
||||
}
|
||||
}
|
1511
app/src/main/java/com/pixelmintdrop/game/GameView.kt
Normal file
1511
app/src/main/java/com/pixelmintdrop/game/GameView.kt
Normal file
File diff suppressed because it is too large
Load diff
512
app/src/main/java/com/pixelmintdrop/game/GamepadController.kt
Normal file
512
app/src/main/java/com/pixelmintdrop/game/GamepadController.kt
Normal file
|
@ -0,0 +1,512 @@
|
|||
package com.mintris.game
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.util.Log
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.view.InputDevice.MotionRange
|
||||
import android.os.Vibrator
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* GamepadController handles gamepad input for the Mintris game.
|
||||
* Supports multiple gamepad types including:
|
||||
* - Microsoft Xbox controllers
|
||||
* - Sony PlayStation controllers
|
||||
* - Nintendo Switch controllers
|
||||
* - Backbone controllers
|
||||
*/
|
||||
class GamepadController(
|
||||
private val gameView: GameView
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "GamepadController"
|
||||
|
||||
// Deadzone for analog sticks (normalized value 0.0-1.0)
|
||||
private const val STICK_DEADZONE = 0.30f
|
||||
|
||||
// Cooldown times for responsive input without repeating too quickly
|
||||
private const val MOVE_COOLDOWN_MS = 100L
|
||||
private const val ROTATION_COOLDOWN_MS = 150L
|
||||
private const val HARD_DROP_COOLDOWN_MS = 200L
|
||||
private const val HOLD_COOLDOWN_MS = 250L
|
||||
|
||||
// Continuous movement repeat delay
|
||||
private const val CONTINUOUS_MOVEMENT_DELAY_MS = 100L
|
||||
|
||||
// Rumble patterns
|
||||
private const val RUMBLE_MOVE_DURATION_MS = 20L
|
||||
private const val RUMBLE_ROTATE_DURATION_MS = 30L
|
||||
private const val RUMBLE_HARD_DROP_DURATION_MS = 100L
|
||||
private const val RUMBLE_LINE_CLEAR_DURATION_MS = 150L
|
||||
|
||||
// Check if device is a gamepad
|
||||
fun isGamepad(device: InputDevice?): Boolean {
|
||||
if (device == null) return false
|
||||
|
||||
// Check for gamepad via input device sources
|
||||
val sources = device.sources
|
||||
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
|
||||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
// Get a list of all connected gamepads
|
||||
fun getGamepads(): List<InputDevice> {
|
||||
val gamepads = mutableListOf<InputDevice>()
|
||||
val deviceIds = InputDevice.getDeviceIds()
|
||||
|
||||
for (deviceId in deviceIds) {
|
||||
val device = InputDevice.getDevice(deviceId)
|
||||
if (device != null && isGamepad(device)) {
|
||||
gamepads.add(device)
|
||||
}
|
||||
}
|
||||
|
||||
return gamepads
|
||||
}
|
||||
|
||||
// Check if any gamepad is connected
|
||||
fun isGamepadConnected(): Boolean {
|
||||
return getGamepads().isNotEmpty()
|
||||
}
|
||||
|
||||
// Get the name of the first connected gamepad
|
||||
fun getConnectedGamepadName(): String? {
|
||||
val gamepads = getGamepads()
|
||||
if (gamepads.isEmpty()) return null
|
||||
return gamepads.first().name
|
||||
}
|
||||
|
||||
// Get information about all connected gamepads
|
||||
fun getConnectedGamepadsInfo(): List<String> {
|
||||
return getGamepads().map { it.name }
|
||||
}
|
||||
|
||||
// Check if device supports vibration
|
||||
fun supportsVibration(device: InputDevice?): Boolean {
|
||||
if (device == null) return false
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
|
||||
|
||||
return device.vibratorManager?.vibratorIds?.isNotEmpty() ?: false
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamps for cooldowns
|
||||
private var lastMoveTime = 0L
|
||||
private var lastRotationTime = 0L
|
||||
private var lastHardDropTime = 0L
|
||||
private var lastHoldTime = 0L
|
||||
|
||||
// Track current directional input state
|
||||
private var isMovingLeft = false
|
||||
private var isMovingRight = false
|
||||
private var isMovingDown = false
|
||||
|
||||
// Handler for continuous movement
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val moveLeftRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isMovingLeft && gameView.isActive()) {
|
||||
gameView.moveLeft()
|
||||
vibrateForPieceMove()
|
||||
handler.postDelayed(this, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val moveRightRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isMovingRight && gameView.isActive()) {
|
||||
gameView.moveRight()
|
||||
vibrateForPieceMove()
|
||||
handler.postDelayed(this, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val moveDownRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isMovingDown && gameView.isActive()) {
|
||||
gameView.softDrop()
|
||||
vibrateForPieceMove()
|
||||
handler.postDelayed(this, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Callback interfaces
|
||||
interface GamepadConnectionListener {
|
||||
fun onGamepadConnected(gamepadName: String)
|
||||
fun onGamepadDisconnected(gamepadName: String)
|
||||
}
|
||||
|
||||
interface GamepadMenuListener {
|
||||
fun onPauseRequested()
|
||||
}
|
||||
|
||||
interface GamepadNavigationListener {
|
||||
fun onMenuUp()
|
||||
fun onMenuDown()
|
||||
fun onMenuSelect()
|
||||
fun onMenuLeft()
|
||||
fun onMenuRight()
|
||||
}
|
||||
|
||||
// Listeners
|
||||
private var connectionListener: GamepadConnectionListener? = null
|
||||
private var menuListener: GamepadMenuListener? = null
|
||||
private var navigationListener: GamepadNavigationListener? = null
|
||||
|
||||
// Currently active gamepad for rumble
|
||||
private var activeGamepad: InputDevice? = null
|
||||
|
||||
/**
|
||||
* Set a listener for gamepad connection events
|
||||
*/
|
||||
fun setGamepadConnectionListener(listener: GamepadConnectionListener) {
|
||||
connectionListener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for gamepad menu events (pause/start button)
|
||||
*/
|
||||
fun setGamepadMenuListener(listener: GamepadMenuListener) {
|
||||
menuListener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for gamepad navigation events
|
||||
*/
|
||||
fun setGamepadNavigationListener(listener: GamepadNavigationListener) {
|
||||
navigationListener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to check for newly connected gamepads.
|
||||
* Call this periodically from the activity to detect connection changes.
|
||||
*/
|
||||
fun checkForGamepadChanges(context: Context) {
|
||||
// Implementation would track previous and current gamepads
|
||||
// and notify through the connectionListener
|
||||
// This would be called from the activity's onResume or via a handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrate the gamepad if supported
|
||||
*/
|
||||
fun vibrateGamepad(durationMs: Long, amplitude: Int) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
|
||||
val gamepad = activeGamepad ?: return
|
||||
|
||||
if (supportsVibration(gamepad)) {
|
||||
try {
|
||||
val vibrator = gamepad.vibratorManager
|
||||
val vibratorIds = vibrator.vibratorIds
|
||||
|
||||
if (vibratorIds.isNotEmpty()) {
|
||||
val effect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
VibrationEffect.createOneShot(durationMs, amplitude)
|
||||
} else {
|
||||
// For older devices, fall back to a simple vibration
|
||||
VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
}
|
||||
|
||||
// Create combined vibration for Android S+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val combinedVibration = android.os.CombinedVibration.createParallel(effect)
|
||||
vibrator.vibrate(combinedVibration)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error vibrating gamepad", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrate for piece movement
|
||||
*/
|
||||
fun vibrateForPieceMove() {
|
||||
vibrateGamepad(RUMBLE_MOVE_DURATION_MS, 50)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrate for piece rotation
|
||||
*/
|
||||
fun vibrateForPieceRotation() {
|
||||
vibrateGamepad(RUMBLE_ROTATE_DURATION_MS, 80)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrate for hard drop
|
||||
*/
|
||||
fun vibrateForHardDrop() {
|
||||
vibrateGamepad(RUMBLE_HARD_DROP_DURATION_MS, 150)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrate for line clear
|
||||
*/
|
||||
fun vibrateForLineClear(lineCount: Int) {
|
||||
val amplitude = when (lineCount) {
|
||||
1 -> 100
|
||||
2 -> 150
|
||||
3 -> 200
|
||||
else -> 255 // For tetris (4 lines)
|
||||
}
|
||||
vibrateGamepad(RUMBLE_LINE_CLEAR_DURATION_MS, amplitude)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a key event from a gamepad
|
||||
* @return true if the event was handled, false otherwise
|
||||
*/
|
||||
fun handleKeyEvent(keyCode: Int, event: KeyEvent): Boolean {
|
||||
// Skip if game is not active but handle menu navigation
|
||||
if (!gameView.isActive()) {
|
||||
// Handle menu navigation even when game is not active
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
navigationListener?.onMenuUp()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
navigationListener?.onMenuDown()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
navigationListener?.onMenuLeft()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
navigationListener?.onMenuRight()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_BUTTON_A,
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
navigationListener?.onMenuSelect()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||
// B button can be used to go back/cancel
|
||||
menuListener?.onPauseRequested()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle menu/start button for pause menu
|
||||
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_BUTTON_START) {
|
||||
menuListener?.onPauseRequested()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
val device = event.device
|
||||
if (!isGamepad(device)) return false
|
||||
|
||||
// Update active gamepad for rumble
|
||||
activeGamepad = device
|
||||
|
||||
val currentTime = SystemClock.uptimeMillis()
|
||||
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
when (keyCode) {
|
||||
// D-pad and analog movement
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
if (!isMovingLeft) {
|
||||
gameView.moveLeft()
|
||||
vibrateForPieceMove()
|
||||
lastMoveTime = currentTime
|
||||
isMovingLeft = true
|
||||
// Start continuous movement after initial input
|
||||
handler.postDelayed(moveLeftRunnable, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
return true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
if (!isMovingRight) {
|
||||
gameView.moveRight()
|
||||
vibrateForPieceMove()
|
||||
lastMoveTime = currentTime
|
||||
isMovingRight = true
|
||||
// Start continuous movement after initial input
|
||||
handler.postDelayed(moveRightRunnable, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
return true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
if (!isMovingDown) {
|
||||
gameView.softDrop()
|
||||
vibrateForPieceMove()
|
||||
lastMoveTime = currentTime
|
||||
isMovingDown = true
|
||||
// Start continuous movement after initial input
|
||||
handler.postDelayed(moveDownRunnable, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
return true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
if (currentTime - lastHardDropTime > HARD_DROP_COOLDOWN_MS) {
|
||||
gameView.hardDrop()
|
||||
vibrateForHardDrop()
|
||||
lastHardDropTime = currentTime
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Start button (pause)
|
||||
KeyEvent.KEYCODE_BUTTON_START -> {
|
||||
menuListener?.onPauseRequested()
|
||||
return true
|
||||
}
|
||||
// Rotation buttons - supporting multiple buttons for different controllers
|
||||
KeyEvent.KEYCODE_BUTTON_A,
|
||||
KeyEvent.KEYCODE_BUTTON_X -> {
|
||||
if (currentTime - lastRotationTime > ROTATION_COOLDOWN_MS) {
|
||||
gameView.rotate()
|
||||
vibrateForPieceRotation()
|
||||
lastRotationTime = currentTime
|
||||
return true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||
if (currentTime - lastRotationTime > ROTATION_COOLDOWN_MS) {
|
||||
gameView.rotateCounterClockwise()
|
||||
vibrateForPieceRotation()
|
||||
lastRotationTime = currentTime
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Hold piece buttons
|
||||
KeyEvent.KEYCODE_BUTTON_Y,
|
||||
KeyEvent.KEYCODE_BUTTON_L1,
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> {
|
||||
if (currentTime - lastHoldTime > HOLD_COOLDOWN_MS) {
|
||||
gameView.holdPiece()
|
||||
vibrateForPieceRotation()
|
||||
lastHoldTime = currentTime
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
isMovingLeft = false
|
||||
handler.removeCallbacks(moveLeftRunnable)
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
isMovingRight = false
|
||||
handler.removeCallbacks(moveRightRunnable)
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
isMovingDown = false
|
||||
handler.removeCallbacks(moveDownRunnable)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Process generic motion events (for analog sticks)
|
||||
* @return true if the event was handled, false otherwise
|
||||
*/
|
||||
fun handleMotionEvent(event: MotionEvent): Boolean {
|
||||
// Skip if game is not active
|
||||
if (!gameView.isActive()) return false
|
||||
|
||||
val device = event.device
|
||||
if (!isGamepad(device)) return false
|
||||
|
||||
// Update active gamepad for rumble
|
||||
activeGamepad = device
|
||||
|
||||
val currentTime = SystemClock.uptimeMillis()
|
||||
|
||||
// Process left analog stick
|
||||
val axisX = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
val axisY = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
|
||||
// Apply deadzone
|
||||
if (Math.abs(axisX) > STICK_DEADZONE) {
|
||||
if (axisX > 0 && !isMovingRight) {
|
||||
gameView.moveRight()
|
||||
vibrateForPieceMove()
|
||||
lastMoveTime = currentTime
|
||||
isMovingRight = true
|
||||
isMovingLeft = false
|
||||
|
||||
// Start continuous movement after initial input
|
||||
handler.removeCallbacks(moveLeftRunnable)
|
||||
handler.postDelayed(moveRightRunnable, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
return true
|
||||
} else if (axisX < 0 && !isMovingLeft) {
|
||||
gameView.moveLeft()
|
||||
vibrateForPieceMove()
|
||||
lastMoveTime = currentTime
|
||||
isMovingLeft = true
|
||||
isMovingRight = false
|
||||
|
||||
// Start continuous movement after initial input
|
||||
handler.removeCallbacks(moveRightRunnable)
|
||||
handler.postDelayed(moveLeftRunnable, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Reset horizontal movement flags when stick returns to center
|
||||
isMovingLeft = false
|
||||
isMovingRight = false
|
||||
handler.removeCallbacks(moveLeftRunnable)
|
||||
handler.removeCallbacks(moveRightRunnable)
|
||||
}
|
||||
|
||||
if (Math.abs(axisY) > STICK_DEADZONE) {
|
||||
if (axisY > 0 && !isMovingDown) {
|
||||
gameView.softDrop()
|
||||
vibrateForPieceMove()
|
||||
lastMoveTime = currentTime
|
||||
isMovingDown = true
|
||||
|
||||
// Start continuous movement after initial input
|
||||
handler.postDelayed(moveDownRunnable, CONTINUOUS_MOVEMENT_DELAY_MS)
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Reset vertical movement flag when stick returns to center
|
||||
isMovingDown = false
|
||||
handler.removeCallbacks(moveDownRunnable)
|
||||
}
|
||||
|
||||
// Check right analog stick for rotation
|
||||
val axisZ = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
val axisRZ = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
|
||||
if (Math.abs(axisZ) > STICK_DEADZONE || Math.abs(axisRZ) > STICK_DEADZONE) {
|
||||
if (currentTime - lastRotationTime > ROTATION_COOLDOWN_MS) {
|
||||
gameView.rotate()
|
||||
vibrateForPieceRotation()
|
||||
lastRotationTime = currentTime
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
134
app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt
Normal file
134
app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt
Normal file
|
@ -0,0 +1,134 @@
|
|||
package com.mintris.game
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import com.mintris.model.GameBoard
|
||||
import com.mintris.model.Tetromino
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* View that displays the currently held piece
|
||||
*/
|
||||
class HoldPieceView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var gameView: GameView? = null
|
||||
private var gameBoard: GameBoard? = null
|
||||
|
||||
// Rendering
|
||||
private val blockPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val glowPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
alpha = 40
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 1.5f
|
||||
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER)
|
||||
}
|
||||
|
||||
private val blockGlowPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
alpha = 60
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.FILL
|
||||
maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the game view reference
|
||||
*/
|
||||
fun setGameView(view: GameView) {
|
||||
gameView = view
|
||||
gameBoard = view.getGameBoard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the game board reference
|
||||
*/
|
||||
private fun getGameBoard(): GameBoard? = gameBoard
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
// Get the held piece from game board
|
||||
gameBoard?.let {
|
||||
it.getHoldPiece()?.let { piece ->
|
||||
val width = piece.getWidth()
|
||||
val height = piece.getHeight()
|
||||
|
||||
// Calculate block size for the preview (smaller than main board)
|
||||
val previewBlockSize = min(
|
||||
canvas.width.toFloat() / (width + 2),
|
||||
canvas.height.toFloat() / (height + 2)
|
||||
)
|
||||
|
||||
// Center the piece in the preview area
|
||||
val previewLeft = (canvas.width - width * previewBlockSize) / 2
|
||||
val previewTop = (canvas.height - height * previewBlockSize) / 2
|
||||
|
||||
// Draw subtle background glow
|
||||
val glowPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
alpha = 10
|
||||
maskFilter = BlurMaskFilter(previewBlockSize * 0.5f, BlurMaskFilter.Blur.OUTER)
|
||||
}
|
||||
canvas.drawRect(
|
||||
previewLeft - previewBlockSize,
|
||||
previewTop - previewBlockSize,
|
||||
previewLeft + width * previewBlockSize + previewBlockSize,
|
||||
previewTop + height * previewBlockSize + previewBlockSize,
|
||||
glowPaint
|
||||
)
|
||||
|
||||
// Draw the held piece
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
if (piece.isBlockAt(x, y)) {
|
||||
val left = previewLeft + x * previewBlockSize
|
||||
val top = previewTop + y * previewBlockSize
|
||||
val right = left + previewBlockSize
|
||||
val bottom = top + previewBlockSize
|
||||
|
||||
// Draw outer glow
|
||||
blockGlowPaint.color = Color.WHITE
|
||||
canvas.drawRect(
|
||||
left - 2f,
|
||||
top - 2f,
|
||||
right + 2f,
|
||||
bottom + 2f,
|
||||
blockGlowPaint
|
||||
)
|
||||
|
||||
// Draw block
|
||||
blockPaint.color = Color.WHITE
|
||||
canvas.drawRect(left, top, right, bottom, blockPaint)
|
||||
|
||||
// Draw inner glow
|
||||
glowPaint.color = Color.WHITE
|
||||
canvas.drawRect(
|
||||
left + 1f,
|
||||
top + 1f,
|
||||
right - 1f,
|
||||
bottom - 1f,
|
||||
glowPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
99
app/src/main/java/com/pixelmintdrop/game/NextPieceView.kt
Normal file
99
app/src/main/java/com/pixelmintdrop/game/NextPieceView.kt
Normal file
|
@ -0,0 +1,99 @@
|
|||
package com.mintris.game
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Custom view to display the next Tetromino piece
|
||||
*/
|
||||
class NextPieceView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var gameView: GameView? = null
|
||||
|
||||
// Rendering
|
||||
private val blockPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val glowPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
alpha = 30
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 1.5f
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the game view to get the next piece from
|
||||
*/
|
||||
fun setGameView(gameView: GameView) {
|
||||
this.gameView = gameView
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
// Get the next piece from game view
|
||||
gameView?.let {
|
||||
it.getNextPiece()?.let { piece ->
|
||||
val width = piece.getWidth()
|
||||
val height = piece.getHeight()
|
||||
|
||||
// Calculate block size for the preview (smaller than main board)
|
||||
val previewBlockSize = min(
|
||||
canvas.width.toFloat() / (width + 2),
|
||||
canvas.height.toFloat() / (height + 2)
|
||||
)
|
||||
|
||||
// Center the piece in the preview area
|
||||
val previewLeft = (canvas.width - width * previewBlockSize) / 2
|
||||
val previewTop = (canvas.height - height * previewBlockSize) / 2
|
||||
|
||||
// Draw subtle background glow
|
||||
val glowPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
alpha = 10
|
||||
maskFilter = BlurMaskFilter(previewBlockSize * 0.5f, BlurMaskFilter.Blur.OUTER)
|
||||
}
|
||||
canvas.drawRect(
|
||||
previewLeft - previewBlockSize,
|
||||
previewTop - previewBlockSize,
|
||||
previewLeft + width * previewBlockSize + previewBlockSize,
|
||||
previewTop + height * previewBlockSize + previewBlockSize,
|
||||
glowPaint
|
||||
)
|
||||
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
if (piece.isBlockAt(x, y)) {
|
||||
val left = previewLeft + x * previewBlockSize
|
||||
val top = previewTop + y * previewBlockSize
|
||||
val right = left + previewBlockSize
|
||||
val bottom = top + previewBlockSize
|
||||
|
||||
// Draw block with subtle glow
|
||||
val rect = RectF(left + 1, top + 1, right - 1, bottom - 1)
|
||||
canvas.drawRect(rect, blockPaint)
|
||||
|
||||
// Draw subtle border glow
|
||||
val glowRect = RectF(left, top, right, bottom)
|
||||
canvas.drawRect(glowRect, glowPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
386
app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt
Normal file
386
app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt
Normal file
|
@ -0,0 +1,386 @@
|
|||
package com.mintris.game
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import java.util.Random
|
||||
import android.util.Log
|
||||
import com.mintris.model.HighScoreManager
|
||||
import com.mintris.model.HighScore
|
||||
import com.mintris.model.PlayerProgressionManager
|
||||
import kotlin.math.abs
|
||||
import androidx.core.graphics.withTranslation
|
||||
import androidx.core.graphics.withScale
|
||||
import androidx.core.graphics.withRotation
|
||||
|
||||
class TitleScreen @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val paint = Paint()
|
||||
private val glowPaint = Paint()
|
||||
private val titlePaint = Paint()
|
||||
private val promptPaint = Paint()
|
||||
private val highScorePaint = Paint()
|
||||
private val cellSize = 30f
|
||||
private val random = Random()
|
||||
private var width = 0
|
||||
private var height = 0
|
||||
private val tetrominosToAdd = mutableListOf<Tetromino>()
|
||||
private val highScoreManager = HighScoreManager(context) // Pre-allocate HighScoreManager
|
||||
|
||||
// Touch handling variables
|
||||
private var startX = 0f
|
||||
private var startY = 0f
|
||||
private var lastTouchX = 0f
|
||||
private var lastTouchY = 0f
|
||||
private val maxTapMovement = 20f // Maximum movement allowed for a tap (in pixels)
|
||||
|
||||
// Callback for when the user touches the screen
|
||||
var onStartGame: (() -> Unit)? = null
|
||||
|
||||
// Theme color and background color
|
||||
private var themeColor = Color.WHITE
|
||||
private var backgroundColor = Color.BLACK
|
||||
|
||||
// Define tetromino shapes (I, O, T, S, Z, J, L)
|
||||
private val tetrominoShapes = arrayOf(
|
||||
// I
|
||||
arrayOf(
|
||||
intArrayOf(0, 0, 0, 0),
|
||||
intArrayOf(1, 1, 1, 1),
|
||||
intArrayOf(0, 0, 0, 0),
|
||||
intArrayOf(0, 0, 0, 0)
|
||||
),
|
||||
// O
|
||||
arrayOf(
|
||||
intArrayOf(1, 1),
|
||||
intArrayOf(1, 1)
|
||||
),
|
||||
// T
|
||||
arrayOf(
|
||||
intArrayOf(0, 1, 0),
|
||||
intArrayOf(1, 1, 1),
|
||||
intArrayOf(0, 0, 0)
|
||||
),
|
||||
// S
|
||||
arrayOf(
|
||||
intArrayOf(0, 1, 1),
|
||||
intArrayOf(1, 1, 0),
|
||||
intArrayOf(0, 0, 0)
|
||||
),
|
||||
// Z
|
||||
arrayOf(
|
||||
intArrayOf(1, 1, 0),
|
||||
intArrayOf(0, 1, 1),
|
||||
intArrayOf(0, 0, 0)
|
||||
),
|
||||
// J
|
||||
arrayOf(
|
||||
intArrayOf(1, 0, 0),
|
||||
intArrayOf(1, 1, 1),
|
||||
intArrayOf(0, 0, 0)
|
||||
),
|
||||
// L
|
||||
arrayOf(
|
||||
intArrayOf(0, 0, 1),
|
||||
intArrayOf(1, 1, 1),
|
||||
intArrayOf(0, 0, 0)
|
||||
)
|
||||
)
|
||||
|
||||
// Tetromino class to represent falling pieces
|
||||
private class Tetromino(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
val shape: Array<IntArray>,
|
||||
val speed: Float,
|
||||
val scale: Float,
|
||||
val rotation: Int = 0
|
||||
)
|
||||
|
||||
private val tetrominos = mutableListOf<Tetromino>()
|
||||
|
||||
init {
|
||||
// Title text settings
|
||||
titlePaint.apply {
|
||||
color = themeColor
|
||||
textSize = 120f
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
// "Touch to start" text settings
|
||||
promptPaint.apply {
|
||||
color = themeColor
|
||||
textSize = 50f
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
|
||||
isAntiAlias = true
|
||||
alpha = 180
|
||||
}
|
||||
|
||||
// High scores text settings
|
||||
highScorePaint.apply {
|
||||
color = themeColor
|
||||
textSize = 70f
|
||||
textAlign = Paint.Align.LEFT // Changed to LEFT alignment
|
||||
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL) // Changed to monospace
|
||||
isAntiAlias = true
|
||||
alpha = 200
|
||||
}
|
||||
|
||||
// General paint settings for tetrominos
|
||||
paint.apply {
|
||||
color = themeColor
|
||||
style = Paint.Style.FILL
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
// Glow paint settings for tetrominos
|
||||
glowPaint.apply {
|
||||
color = themeColor
|
||||
style = Paint.Style.FILL
|
||||
isAntiAlias = true
|
||||
alpha = 60
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
width = w
|
||||
height = h
|
||||
|
||||
// Clear existing tetrominos
|
||||
tetrominos.clear()
|
||||
|
||||
// Initialize some tetrominos
|
||||
repeat(20) {
|
||||
val tetromino = createRandomTetromino()
|
||||
tetrominos.add(tetromino)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRandomTetromino(): Tetromino {
|
||||
val x = random.nextFloat() * (width - 150) + 50 // Keep away from edges
|
||||
val y = -cellSize * 4 - (random.nextFloat() * height / 2)
|
||||
val shapeIndex = random.nextInt(tetrominoShapes.size)
|
||||
val shape = tetrominoShapes[shapeIndex]
|
||||
val speed = 1f + random.nextFloat() * 2f
|
||||
val scale = 0.8f + random.nextFloat() * 0.4f
|
||||
val rotation = random.nextInt(4) * 90
|
||||
|
||||
return Tetromino(x, y, shape, speed, scale, rotation)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
try {
|
||||
super.onDraw(canvas)
|
||||
|
||||
// Draw background using the current background color
|
||||
canvas.drawColor(backgroundColor)
|
||||
|
||||
// Add any pending tetrominos
|
||||
tetrominos.addAll(tetrominosToAdd)
|
||||
tetrominosToAdd.clear()
|
||||
|
||||
// Update and draw falling tetrominos
|
||||
val tetrominosToRemove = mutableListOf<Tetromino>()
|
||||
|
||||
for (tetromino in tetrominos) {
|
||||
tetromino.y += tetromino.speed
|
||||
|
||||
// Remove tetrominos that have fallen off the screen
|
||||
if (tetromino.y > height) {
|
||||
tetrominosToRemove.add(tetromino)
|
||||
tetrominosToAdd.add(createRandomTetromino())
|
||||
} else {
|
||||
try {
|
||||
// Draw the tetromino
|
||||
for (y in 0 until tetromino.shape.size) {
|
||||
for (x in 0 until tetromino.shape.size) {
|
||||
if (tetromino.shape[y][x] == 1) {
|
||||
val left = x * cellSize
|
||||
val top = y * cellSize
|
||||
val right = left + cellSize
|
||||
val bottom = top + cellSize
|
||||
|
||||
// Draw block with glow effect
|
||||
canvas.withTranslation(tetromino.x, tetromino.y) {
|
||||
withScale(tetromino.scale, tetromino.scale) {
|
||||
withRotation(tetromino.rotation.toFloat(),
|
||||
tetromino.shape.size * cellSize / 2,
|
||||
tetromino.shape.size * cellSize / 2) {
|
||||
// Draw glow
|
||||
canvas.drawRect(left - 8f, top - 8f, right + 8f, bottom + 8f, glowPaint)
|
||||
// Draw block
|
||||
canvas.drawRect(left, top, right, bottom, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("TitleScreen", "Error drawing tetromino", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove tetrominos that fell off the screen
|
||||
tetrominos.removeAll(tetrominosToRemove)
|
||||
|
||||
// Draw title
|
||||
val titleY = height * 0.4f
|
||||
canvas.drawText("mintris", width / 2f, titleY, titlePaint)
|
||||
|
||||
// Draw high scores using pre-allocated manager
|
||||
val highScores: List<HighScore> = highScoreManager.getHighScores()
|
||||
val highScoreY = height * 0.5f
|
||||
var lastHighScoreY = highScoreY
|
||||
if (highScores.isNotEmpty()) {
|
||||
// Calculate the starting X position to center the entire block of scores
|
||||
val maxScoreWidth = highScorePaint.measureText("99. PLAYER: 999999")
|
||||
val startX = (width - maxScoreWidth) / 2
|
||||
|
||||
highScores.forEachIndexed { index: Int, score: HighScore ->
|
||||
val y = highScoreY + (index * 80f)
|
||||
lastHighScoreY = y // Track the last high score's Y position
|
||||
// Pad the rank number to ensure alignment
|
||||
val rank = (index + 1).toString().padStart(2, ' ')
|
||||
// Pad the name to ensure score alignment
|
||||
val paddedName = score.name.padEnd(8, ' ')
|
||||
canvas.drawText("$rank. $paddedName ${score.score}", startX, y, highScorePaint)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw "touch to start" prompt below the high scores
|
||||
val promptY = if (resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) {
|
||||
// In landscape mode, position below the last high score with some padding
|
||||
lastHighScoreY + 100f
|
||||
} else {
|
||||
// In portrait mode, use the original position
|
||||
height * 0.7f
|
||||
}
|
||||
canvas.drawText("touch to start", width / 2f, promptY, promptPaint)
|
||||
|
||||
// Request another frame
|
||||
invalidate()
|
||||
} catch (e: Exception) {
|
||||
Log.e("TitleScreen", "Error in onDraw", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
startX = event.x
|
||||
startY = event.y
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val deltaX = event.x - lastTouchX
|
||||
val deltaY = event.y - lastTouchY
|
||||
|
||||
// Update tetromino positions
|
||||
for (tetromino in tetrominos) {
|
||||
tetromino.x += deltaX * 0.5f
|
||||
tetromino.y += deltaY * 0.5f
|
||||
}
|
||||
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val deltaX = event.x - startX
|
||||
val deltaY = event.y - startY
|
||||
|
||||
// If the movement was minimal, treat as a tap
|
||||
if (abs(deltaX) < maxTapMovement && abs(deltaY) < maxTapMovement) {
|
||||
performClick()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun performClick(): Boolean {
|
||||
// Call the superclass's performClick
|
||||
super.performClick()
|
||||
|
||||
// Handle the click event
|
||||
onStartGame?.invoke()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a theme to the title screen
|
||||
*/
|
||||
fun applyTheme(themeId: String) {
|
||||
// Get theme color based on theme ID
|
||||
themeColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#FF00FF")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.LTGRAY
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#FF5A5F")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#66FCF1")
|
||||
else -> Color.WHITE
|
||||
}
|
||||
|
||||
// Update paint colors
|
||||
titlePaint.color = themeColor
|
||||
promptPaint.color = themeColor
|
||||
highScorePaint.color = themeColor
|
||||
paint.color = themeColor
|
||||
glowPaint.color = themeColor
|
||||
|
||||
// Update background color
|
||||
backgroundColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
|
||||
else -> Color.BLACK
|
||||
}
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the theme color for the title screen
|
||||
*/
|
||||
fun setThemeColor(color: Int) {
|
||||
themeColor = color
|
||||
titlePaint.color = color
|
||||
promptPaint.color = color
|
||||
highScorePaint.color = color
|
||||
paint.color = color
|
||||
glowPaint.color = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the background color for the title screen
|
||||
*/
|
||||
override fun setBackgroundColor(color: Int) {
|
||||
backgroundColor = color
|
||||
invalidate()
|
||||
}
|
||||
}
|
765
app/src/main/java/com/pixelmintdrop/model/GameBoard.kt
Normal file
765
app/src/main/java/com/pixelmintdrop/model/GameBoard.kt
Normal file
|
@ -0,0 +1,765 @@
|
|||
package com.mintris.model
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Represents the game board (grid) and manages game state
|
||||
*/
|
||||
class GameBoard(
|
||||
val width: Int = 10,
|
||||
val height: Int = 20
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "GameBoard"
|
||||
}
|
||||
|
||||
// Board grid to track locked pieces
|
||||
// True = occupied, False = empty
|
||||
private val grid = Array(height) { BooleanArray(width) { false } }
|
||||
|
||||
// Current active tetromino
|
||||
private var currentPiece: Tetromino? = null
|
||||
|
||||
// Next tetromino to be played
|
||||
private var nextPiece: Tetromino? = null
|
||||
|
||||
// Hold piece
|
||||
private var holdPiece: Tetromino? = null
|
||||
private var canHold = true
|
||||
|
||||
// 7-bag randomizer
|
||||
private val bag = mutableListOf<TetrominoType>()
|
||||
|
||||
// Game state
|
||||
var score = 0
|
||||
var level = 1
|
||||
var startingLevel = 1 // Add this line to track the starting level
|
||||
var lines = 0
|
||||
var isGameOver = false
|
||||
var isHardDropInProgress = false // Make public
|
||||
var isPieceLocking = false // Make public
|
||||
private var isPlayerSoftDrop = false // Track if the drop is player-initiated
|
||||
private var lastLevel = 1 // Add this to track the previous level
|
||||
|
||||
// Scoring state
|
||||
private var combo = 0
|
||||
private var lastClearWasTetris = false
|
||||
private var lastClearWasPerfect = false
|
||||
private var lastClearWasAllClear = false
|
||||
private var lastPieceClearedLines = false // Track if the last piece placed cleared lines
|
||||
|
||||
// Animation state
|
||||
var linesToClear = mutableListOf<Int>()
|
||||
var isLineClearAnimationInProgress = false
|
||||
|
||||
// Initial game speed (milliseconds per drop)
|
||||
var dropInterval = 1000L
|
||||
|
||||
// Callbacks for game events
|
||||
var onPieceMove: (() -> Unit)? = null
|
||||
var onPieceLock: (() -> Unit)? = null
|
||||
var onNextPieceChanged: (() -> Unit)? = null
|
||||
var onLineClear: ((Int, List<Int>) -> Unit)? = null
|
||||
var onPiecePlaced: (() -> Unit)? = null // New callback for when a piece is placed
|
||||
|
||||
// Store the last cleared lines
|
||||
private val lastClearedLines = mutableListOf<Int>()
|
||||
|
||||
// Add spawn protection variables
|
||||
private var pieceSpawnTime = 0L
|
||||
private val spawnGracePeriod = 250L // Changed from 150ms to 250ms
|
||||
|
||||
init {
|
||||
spawnNextPiece()
|
||||
spawnPiece()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next tetromino piece using 7-bag randomizer
|
||||
*/
|
||||
private fun spawnNextPiece() {
|
||||
// If bag is empty, refill it with all piece types
|
||||
if (bag.isEmpty()) {
|
||||
bag.addAll(TetrominoType.entries.toTypedArray())
|
||||
bag.shuffle()
|
||||
}
|
||||
|
||||
// Take the next piece from the bag
|
||||
nextPiece = Tetromino(bag.removeAt(0))
|
||||
onNextPieceChanged?.invoke()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the current piece
|
||||
*/
|
||||
fun holdPiece() {
|
||||
if (!canHold) return
|
||||
|
||||
val current = currentPiece
|
||||
if (holdPiece == null) {
|
||||
// If no piece is held, hold current piece and spawn new one
|
||||
holdPiece = current
|
||||
currentPiece = nextPiece
|
||||
spawnNextPiece()
|
||||
// Reset position of new piece
|
||||
currentPiece?.apply {
|
||||
x = (width - getWidth()) / 2
|
||||
y = 0
|
||||
}
|
||||
} else {
|
||||
// Swap current piece with held piece
|
||||
currentPiece = holdPiece
|
||||
holdPiece = current
|
||||
// Reset position of swapped piece
|
||||
currentPiece?.apply {
|
||||
x = (width - getWidth()) / 2
|
||||
y = 0
|
||||
}
|
||||
}
|
||||
canHold = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently held piece
|
||||
*/
|
||||
fun getHoldPiece(): Tetromino? = holdPiece
|
||||
|
||||
/**
|
||||
* Get the next piece that will be spawned
|
||||
*/
|
||||
fun getNextPiece(): Tetromino? = nextPiece
|
||||
|
||||
/**
|
||||
* Spawns the current tetromino at the top of the board
|
||||
*/
|
||||
fun spawnPiece() {
|
||||
Log.d(TAG, "spawnPiece() started - current states: isHardDropInProgress=$isHardDropInProgress, isPieceLocking=$isPieceLocking")
|
||||
|
||||
currentPiece = nextPiece
|
||||
spawnNextPiece()
|
||||
|
||||
// Center the piece horizontally and spawn one unit higher
|
||||
currentPiece?.apply {
|
||||
x = (width - getWidth()) / 2
|
||||
y = -1 // Spawn one unit above the top of the screen
|
||||
|
||||
Log.d(TAG, "spawnPiece() - new piece spawned at position (${x},${y}), type=${type}")
|
||||
|
||||
// Set the spawn time for the grace period
|
||||
pieceSpawnTime = System.currentTimeMillis()
|
||||
|
||||
// Check if the piece can be placed (Game Over condition)
|
||||
if (!canMove(0, 0)) {
|
||||
isGameOver = true
|
||||
Log.d(TAG, "spawnPiece() - Game Over condition detected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the current piece left
|
||||
*/
|
||||
fun moveLeft() {
|
||||
if (canMove(-1, 0)) {
|
||||
currentPiece?.x = currentPiece?.x?.minus(1) ?: 0
|
||||
onPieceMove?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the current piece right
|
||||
*/
|
||||
fun moveRight() {
|
||||
if (canMove(1, 0)) {
|
||||
currentPiece?.x = currentPiece?.x?.plus(1) ?: 0
|
||||
onPieceMove?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the current piece down (soft drop)
|
||||
*/
|
||||
fun moveDown(): Boolean {
|
||||
// Don't allow movement if a hard drop is in progress or piece is locking
|
||||
if (isHardDropInProgress || isPieceLocking) return false
|
||||
|
||||
return if (canMove(0, 1)) {
|
||||
currentPiece?.y = currentPiece?.y?.plus(1) ?: 0
|
||||
// Only add soft drop points if it's a player-initiated drop
|
||||
if (isPlayerSoftDrop) {
|
||||
score += 1
|
||||
}
|
||||
onPieceMove?.invoke()
|
||||
true
|
||||
} else {
|
||||
// Check if we're within the spawn grace period
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - pieceSpawnTime < spawnGracePeriod) {
|
||||
Log.d(TAG, "moveDown() - not locking piece due to spawn grace period (${currentTime - pieceSpawnTime}ms < ${spawnGracePeriod}ms)")
|
||||
return false
|
||||
}
|
||||
|
||||
lockPiece()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Player-initiated soft drop
|
||||
*/
|
||||
fun softDrop() {
|
||||
isPlayerSoftDrop = true
|
||||
moveDown()
|
||||
isPlayerSoftDrop = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard drop the current piece
|
||||
*/
|
||||
fun hardDrop() {
|
||||
if (isHardDropInProgress || isPieceLocking) {
|
||||
Log.d(TAG, "hardDrop() called but blocked: isHardDropInProgress=$isHardDropInProgress, isPieceLocking=$isPieceLocking")
|
||||
return // Prevent multiple hard drops
|
||||
}
|
||||
|
||||
// Check if we're within the spawn grace period
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - pieceSpawnTime < spawnGracePeriod) {
|
||||
Log.d(TAG, "hardDrop() - blocked due to spawn grace period (${currentTime - pieceSpawnTime}ms < ${spawnGracePeriod}ms)")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "hardDrop() started - setting isHardDropInProgress=true")
|
||||
isHardDropInProgress = true
|
||||
val piece = currentPiece ?: return
|
||||
|
||||
// Count how many cells the piece will drop
|
||||
var dropDistance = 0
|
||||
while (canMove(0, dropDistance + 1)) {
|
||||
dropDistance++
|
||||
}
|
||||
|
||||
Log.d(TAG, "hardDrop() - piece will drop $dropDistance cells, position before: (${piece.x},${piece.y})")
|
||||
|
||||
// Move piece down until it can't move anymore
|
||||
while (canMove(0, 1)) {
|
||||
piece.y++
|
||||
onPieceMove?.invoke()
|
||||
}
|
||||
|
||||
Log.d(TAG, "hardDrop() - piece final position: (${piece.x},${piece.y})")
|
||||
|
||||
// Add hard drop points (2 points per cell)
|
||||
score += dropDistance * 2
|
||||
|
||||
// Lock the piece immediately
|
||||
lockPiece()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the current piece clockwise
|
||||
*/
|
||||
fun rotate() {
|
||||
currentPiece?.let {
|
||||
// Save current rotation
|
||||
val originalX = it.x
|
||||
val originalY = it.y
|
||||
|
||||
// Try to rotate
|
||||
it.rotateClockwise()
|
||||
|
||||
// Wall kick logic - try to move the piece if rotation causes collision
|
||||
if (!canMove(0, 0)) {
|
||||
// Try to move left
|
||||
if (canMove(-1, 0)) {
|
||||
it.x--
|
||||
}
|
||||
// Try to move right
|
||||
else if (canMove(1, 0)) {
|
||||
it.x++
|
||||
}
|
||||
// Try to move 2 spaces (for I piece)
|
||||
else if (canMove(-2, 0)) {
|
||||
it.x -= 2
|
||||
}
|
||||
else if (canMove(2, 0)) {
|
||||
it.x += 2
|
||||
}
|
||||
// Try to move up for floor kicks
|
||||
else if (canMove(0, -1)) {
|
||||
it.y--
|
||||
}
|
||||
// Revert if can't find a valid position
|
||||
else {
|
||||
it.rotateCounterClockwise()
|
||||
it.x = originalX
|
||||
it.y = originalY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the current piece counterclockwise
|
||||
*/
|
||||
fun rotateCounterClockwise() {
|
||||
currentPiece?.let {
|
||||
// Save current rotation
|
||||
val originalX = it.x
|
||||
val originalY = it.y
|
||||
|
||||
// Try to rotate
|
||||
it.rotateCounterClockwise()
|
||||
|
||||
// Wall kick logic - try to move the piece if rotation causes collision
|
||||
if (!canMove(0, 0)) {
|
||||
// Try to move left
|
||||
if (canMove(-1, 0)) {
|
||||
it.x--
|
||||
}
|
||||
// Try to move right
|
||||
else if (canMove(1, 0)) {
|
||||
it.x++
|
||||
}
|
||||
// Try to move 2 spaces (for I piece)
|
||||
else if (canMove(-2, 0)) {
|
||||
it.x -= 2
|
||||
}
|
||||
else if (canMove(2, 0)) {
|
||||
it.x += 2
|
||||
}
|
||||
// Try to move up for floor kicks
|
||||
else if (canMove(0, -1)) {
|
||||
it.y--
|
||||
}
|
||||
// Revert if can't find a valid position
|
||||
else {
|
||||
it.rotateClockwise()
|
||||
it.x = originalX
|
||||
it.y = originalY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current piece can move to the given position
|
||||
*/
|
||||
fun canMove(deltaX: Int, deltaY: Int): Boolean {
|
||||
val piece = currentPiece ?: return false
|
||||
|
||||
val newX = piece.x + deltaX
|
||||
val newY = piece.y + deltaY
|
||||
|
||||
for (y in 0 until piece.getHeight()) {
|
||||
for (x in 0 until piece.getWidth()) {
|
||||
if (piece.isBlockAt(x, y)) {
|
||||
val boardX = newX + x
|
||||
val boardY = newY + y
|
||||
|
||||
// Check if the position is outside the board horizontally
|
||||
if (boardX < 0 || boardX >= width) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the position is below the board
|
||||
if (boardY >= height) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the position is already occupied (but not if it's above the board)
|
||||
if (boardY >= 0 && grid[boardY][boardX]) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the position is more than one unit above the top of the screen
|
||||
if (boardY < -1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the current piece in place
|
||||
*/
|
||||
private fun lockPiece() {
|
||||
if (isPieceLocking) {
|
||||
Log.d(TAG, "lockPiece() called but blocked: isPieceLocking=$isPieceLocking")
|
||||
return // Prevent recursive locking
|
||||
}
|
||||
|
||||
Log.d(TAG, "lockPiece() started - setting isPieceLocking=true, current isHardDropInProgress=$isHardDropInProgress")
|
||||
isPieceLocking = true
|
||||
|
||||
val piece = currentPiece ?: return
|
||||
|
||||
// Add the piece to the grid
|
||||
for (y in 0 until piece.getHeight()) {
|
||||
for (x in 0 until piece.getWidth()) {
|
||||
if (piece.isBlockAt(x, y)) {
|
||||
val boardX = piece.x + x
|
||||
val boardY = piece.y + y
|
||||
|
||||
// Only add to grid if within bounds
|
||||
if (boardY >= 0 && boardY < height && boardX >= 0 && boardX < width) {
|
||||
grid[boardY][boardX] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the piece lock vibration
|
||||
onPieceLock?.invoke()
|
||||
|
||||
// Notify that a piece was placed
|
||||
onPiecePlaced?.invoke()
|
||||
|
||||
// Find and clear lines immediately
|
||||
findAndClearLines()
|
||||
|
||||
// IMPORTANT: Reset the hard drop flag before spawning a new piece
|
||||
// This prevents the immediate hard drop of the next piece
|
||||
if (isHardDropInProgress) {
|
||||
Log.d(TAG, "lockPiece() - resetting isHardDropInProgress=false BEFORE spawning new piece")
|
||||
isHardDropInProgress = false
|
||||
}
|
||||
|
||||
// Log piece position before spawning new piece
|
||||
Log.d(TAG, "lockPiece() - about to spawn new piece at y=${piece.y}, isHardDropInProgress=$isHardDropInProgress")
|
||||
|
||||
// Spawn new piece immediately
|
||||
spawnPiece()
|
||||
|
||||
// Allow holding piece again after locking
|
||||
canHold = true
|
||||
|
||||
// Reset locking state
|
||||
isPieceLocking = false
|
||||
Log.d(TAG, "lockPiece() completed - reset flags: isPieceLocking=false, isHardDropInProgress=$isHardDropInProgress")
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and clear completed lines immediately
|
||||
*/
|
||||
private fun findAndClearLines() {
|
||||
// Quick scan for completed lines
|
||||
var shiftAmount = 0
|
||||
var y = height - 1
|
||||
val linesToClear = mutableListOf<Int>()
|
||||
|
||||
while (y >= 0) {
|
||||
if (grid[y].all { it }) {
|
||||
// Line is full, add to lines to clear
|
||||
linesToClear.add(y)
|
||||
shiftAmount++
|
||||
} else if (shiftAmount > 0) {
|
||||
// Shift this row down by shiftAmount
|
||||
System.arraycopy(grid[y], 0, grid[y + shiftAmount], 0, width)
|
||||
}
|
||||
y--
|
||||
}
|
||||
|
||||
// Store the last cleared lines
|
||||
lastClearedLines.clear()
|
||||
lastClearedLines.addAll(linesToClear)
|
||||
|
||||
// If lines were cleared, calculate score in background and trigger callback
|
||||
if (shiftAmount > 0) {
|
||||
// Log line clear
|
||||
Log.d(TAG, "Lines cleared: $shiftAmount")
|
||||
|
||||
// Trigger line clear callback on main thread with the lines that were cleared
|
||||
val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
mainHandler.post {
|
||||
// Call the line clear callback with the cleared line count
|
||||
try {
|
||||
Log.d(TAG, "Triggering onLineClear callback with $shiftAmount lines")
|
||||
val clearedLines = getLastClearedLines()
|
||||
onLineClear?.invoke(shiftAmount, clearedLines)
|
||||
Log.d(TAG, "onLineClear callback completed successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in onLineClear callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear top rows after callback
|
||||
for (y in 0 until shiftAmount) {
|
||||
java.util.Arrays.fill(grid[y], false)
|
||||
}
|
||||
|
||||
Thread {
|
||||
calculateScore(shiftAmount)
|
||||
}.start()
|
||||
}
|
||||
|
||||
// Update combo based on whether this piece cleared lines
|
||||
if (shiftAmount > 0) {
|
||||
if (lastPieceClearedLines) {
|
||||
combo++
|
||||
} else {
|
||||
combo = 1 // Start new combo
|
||||
}
|
||||
} else {
|
||||
combo = 0 // Reset combo if no lines cleared
|
||||
}
|
||||
lastPieceClearedLines = shiftAmount > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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()
|
||||
|
||||
// Update score on main thread
|
||||
Thread {
|
||||
score += finalScore
|
||||
}.start()
|
||||
|
||||
// Update line clear state
|
||||
lastClearWasTetris = clearedLines == 4
|
||||
lastClearWasPerfect = isPerfectClear
|
||||
lastClearWasAllClear = isAllClear
|
||||
|
||||
// Update lines cleared and level
|
||||
lines += clearedLines
|
||||
// Calculate level based on lines cleared, but ensure it's never below the starting level
|
||||
level = Math.max((lines / 10) + 1, startingLevel)
|
||||
|
||||
// Update game speed based on level (NES formula)
|
||||
dropInterval = (1000 * Math.pow(0.8, (level - 1).toDouble())).toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last move was a T-Spin
|
||||
*/
|
||||
private fun isTSpin(): Boolean {
|
||||
val piece = currentPiece ?: return false
|
||||
if (piece.type != TetrominoType.T) return false
|
||||
|
||||
// Count occupied corners around the T piece
|
||||
var occupiedCorners = 0
|
||||
val centerX = piece.x + 1
|
||||
val centerY = piece.y + 1
|
||||
|
||||
// Check all four corners
|
||||
if (isOccupied(centerX - 1, centerY - 1)) occupiedCorners++
|
||||
if (isOccupied(centerX + 1, centerY - 1)) occupiedCorners++
|
||||
if (isOccupied(centerX - 1, centerY + 1)) occupiedCorners++
|
||||
if (isOccupied(centerX + 1, centerY + 1)) occupiedCorners++
|
||||
|
||||
// T-Spin requires at least 3 occupied corners
|
||||
return occupiedCorners >= 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ghost piece position (preview of where piece will land)
|
||||
*/
|
||||
fun getGhostY(): Int {
|
||||
val piece = currentPiece ?: return 0
|
||||
var ghostY = piece.y
|
||||
|
||||
// Find how far the piece can move down
|
||||
while (true) {
|
||||
if (canMove(0, ghostY - piece.y + 1)) {
|
||||
ghostY++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure ghostY doesn't exceed the board height
|
||||
return ghostY.coerceAtMost(height - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tetromino
|
||||
*/
|
||||
fun getCurrentPiece(): Tetromino? = currentPiece
|
||||
|
||||
/**
|
||||
* Check if a cell in the grid is occupied
|
||||
*/
|
||||
fun isOccupied(x: Int, y: Int): Boolean {
|
||||
return if (x in 0 until width && y in 0 until height) {
|
||||
grid[y][x]
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is completely filled
|
||||
*/
|
||||
fun isLineFull(y: Int): Boolean {
|
||||
return if (y in 0 until height) {
|
||||
grid[y].all { it }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current level and adjust game parameters
|
||||
*/
|
||||
fun updateLevel(newLevel: Int) {
|
||||
lastLevel = level
|
||||
level = newLevel.coerceIn(1, 20)
|
||||
startingLevel = level // Store the starting level
|
||||
dropInterval = getDropIntervalForLevel(level)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new game
|
||||
*/
|
||||
fun startGame() {
|
||||
reset()
|
||||
// Initialize pieces
|
||||
spawnNextPiece()
|
||||
spawnPiece()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the game board
|
||||
*/
|
||||
fun reset() {
|
||||
// Clear the grid
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
grid[y][x] = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset game state
|
||||
score = 0
|
||||
level = startingLevel // Use starting level instead of resetting to 1
|
||||
lastLevel = level // Reset lastLevel to match the current level
|
||||
lines = 0
|
||||
isGameOver = false
|
||||
dropInterval = getDropIntervalForLevel(level) // Use helper method
|
||||
|
||||
// Reset scoring state
|
||||
combo = 0
|
||||
lastClearWasTetris = false
|
||||
lastClearWasPerfect = false
|
||||
lastClearWasAllClear = false
|
||||
lastPieceClearedLines = false
|
||||
|
||||
// Reset piece state
|
||||
holdPiece = null
|
||||
canHold = true
|
||||
bag.clear()
|
||||
|
||||
// Clear current and next pieces
|
||||
currentPiece = null
|
||||
nextPiece = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear completed lines and move blocks down (legacy method, kept for reference)
|
||||
*/
|
||||
private fun clearLines(): Int {
|
||||
return linesToClear.size // Return the number of lines that will be cleared
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current combo count
|
||||
*/
|
||||
fun getCombo(): Int = combo
|
||||
|
||||
/**
|
||||
* Get the list of lines that were most recently cleared
|
||||
*/
|
||||
private fun getLastClearedLines(): List<Int> {
|
||||
return lastClearedLines.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last level
|
||||
*/
|
||||
fun getLastLevel(): Int = lastLevel
|
||||
|
||||
/**
|
||||
* Update the game state (called by game loop)
|
||||
*/
|
||||
fun update() {
|
||||
if (!isGameOver) {
|
||||
moveDown()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the drop interval for the given level
|
||||
*/
|
||||
private fun getDropIntervalForLevel(level: Int): Long {
|
||||
val cappedLevel = level.coerceIn(1, 20)
|
||||
// Update game speed based on level (NES formula)
|
||||
return (1000 * Math.pow(0.8, (cappedLevel - 1).toDouble())).toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the game level
|
||||
*/
|
||||
}
|
8
app/src/main/java/com/pixelmintdrop/model/HighScore.kt
Normal file
8
app/src/main/java/com/pixelmintdrop/model/HighScore.kt
Normal 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()
|
||||
)
|
|
@ -0,0 +1,67 @@
|
|||
package com.mintris.model
|
||||
|
||||
import android.graphics.Color
|
||||
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()
|
||||
private var currentTheme = "theme_classic" // Default theme
|
||||
private var textColor = Color.WHITE // Default text color
|
||||
|
||||
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)
|
||||
|
||||
// Apply current text color to elements
|
||||
holder.rankText.setTextColor(textColor)
|
||||
holder.nameText.setTextColor(textColor)
|
||||
holder.scoreText.setTextColor(textColor)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = highScores.size
|
||||
|
||||
class HighScoreViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val rankText: TextView = itemView.findViewById(R.id.rankText)
|
||||
val nameText: TextView = itemView.findViewById(R.id.nameText)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
fun applyTheme(themeId: String) {
|
||||
currentTheme = themeId
|
||||
|
||||
// Update text color based on theme
|
||||
textColor = when (themeId) {
|
||||
"theme_classic" -> Color.WHITE
|
||||
"theme_neon" -> Color.parseColor("#FF00FF")
|
||||
"theme_monochrome" -> Color.LTGRAY
|
||||
"theme_retro" -> Color.parseColor("#FF5A5F")
|
||||
"theme_minimalist" -> Color.BLACK
|
||||
"theme_galaxy" -> Color.parseColor("#66FCF1")
|
||||
else -> Color.WHITE
|
||||
}
|
||||
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,371 @@
|
|||
package com.mintris.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.mintris.R
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Manages player progression, experience points, and unlockable rewards
|
||||
*/
|
||||
class PlayerProgressionManager(context: Context) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Player level and XP
|
||||
private var playerLevel: Int = 1
|
||||
private var playerXP: Long = 0
|
||||
private var totalXPEarned: Long = 0
|
||||
|
||||
// Track unlocked rewards
|
||||
private val unlockedThemes = mutableSetOf<String>()
|
||||
private val unlockedBlocks = mutableSetOf<String>()
|
||||
private val unlockedBadges = mutableSetOf<String>()
|
||||
|
||||
// XP gained in the current session
|
||||
private var sessionXPGained: Long = 0
|
||||
|
||||
init {
|
||||
loadProgress()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load player progression data from shared preferences
|
||||
*/
|
||||
private fun loadProgress() {
|
||||
playerLevel = prefs.getInt(KEY_PLAYER_LEVEL, 1)
|
||||
playerXP = prefs.getLong(KEY_PLAYER_XP, 0)
|
||||
totalXPEarned = prefs.getLong(KEY_TOTAL_XP_EARNED, 0)
|
||||
|
||||
// Load unlocked rewards
|
||||
val themesSet = prefs.getStringSet(KEY_UNLOCKED_THEMES, setOf()) ?: setOf()
|
||||
val blocksSet = prefs.getStringSet(KEY_UNLOCKED_BLOCKS, setOf()) ?: setOf()
|
||||
val badgesSet = prefs.getStringSet(KEY_UNLOCKED_BADGES, setOf()) ?: setOf()
|
||||
|
||||
unlockedThemes.addAll(themesSet)
|
||||
unlockedBlocks.addAll(blocksSet)
|
||||
unlockedBadges.addAll(badgesSet)
|
||||
|
||||
// Add default theme if nothing is unlocked
|
||||
if (unlockedThemes.isEmpty()) {
|
||||
unlockedThemes.add(THEME_CLASSIC)
|
||||
}
|
||||
|
||||
// Always ensure default block skin is present (Level 1)
|
||||
unlockedBlocks.add("block_skin_1")
|
||||
|
||||
// Explicitly check and save all unlocks for the current level on load
|
||||
checkAllUnlocksForCurrentLevel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Save player progression data to shared preferences
|
||||
*/
|
||||
private fun saveProgress() {
|
||||
prefs.edit()
|
||||
.putInt(KEY_PLAYER_LEVEL, playerLevel)
|
||||
.putLong(KEY_PLAYER_XP, playerXP)
|
||||
.putLong(KEY_TOTAL_XP_EARNED, totalXPEarned)
|
||||
.putStringSet(KEY_UNLOCKED_THEMES, unlockedThemes)
|
||||
.putStringSet(KEY_UNLOCKED_BLOCKS, unlockedBlocks)
|
||||
.putStringSet(KEY_UNLOCKED_BADGES, unlockedBadges)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate XP required to reach a specific level
|
||||
*/
|
||||
fun calculateXPForLevel(level: Int): Long {
|
||||
return (BASE_XP * level.toDouble().pow(XP_CURVE_FACTOR)).roundToInt().toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total XP required to reach a certain level from level 1
|
||||
*/
|
||||
fun calculateTotalXPForLevel(level: Int): Long {
|
||||
var totalXP = 0L
|
||||
for (lvl in 1 until level) {
|
||||
totalXP += calculateXPForLevel(lvl)
|
||||
}
|
||||
return totalXP
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate XP from a game session based on score, lines, level, etc.
|
||||
*/
|
||||
fun calculateGameXP(score: Int, lines: Int, level: Int, gameTime: Long,
|
||||
tetrisCount: Int, perfectClearCount: Int): Long {
|
||||
// Base XP from score with level multiplier (capped at level 10)
|
||||
val cappedLevel = min(level, 10)
|
||||
val scoreXP = (score * (1 + LEVEL_MULTIPLIER * cappedLevel)).toLong()
|
||||
|
||||
// XP from lines cleared (reduced for higher levels)
|
||||
val linesXP = lines * XP_PER_LINE * (1 - (level - 1) * 0.05).coerceAtLeast(0.5)
|
||||
|
||||
// XP from special moves (reduced for higher levels)
|
||||
val tetrisBonus = tetrisCount * TETRIS_XP_BONUS * (1 - (level - 1) * 0.05).coerceAtLeast(0.5)
|
||||
val perfectClearBonus = perfectClearCount * PERFECT_CLEAR_XP_BONUS * (1 - (level - 1) * 0.05).coerceAtLeast(0.5)
|
||||
|
||||
// Time bonus (reduced for longer games)
|
||||
val timeBonus = (gameTime / 60000) * TIME_XP_PER_MINUTE * (1 - (gameTime / 3600000) * 0.1).coerceAtLeast(0.5)
|
||||
|
||||
// Calculate total XP
|
||||
return (scoreXP + linesXP + tetrisBonus + perfectClearBonus + timeBonus).toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add XP to the player and handle level-ups
|
||||
* Returns a list of newly unlocked rewards
|
||||
*/
|
||||
fun addXP(xpAmount: Long): List<String> {
|
||||
sessionXPGained = xpAmount
|
||||
playerXP += xpAmount
|
||||
totalXPEarned += xpAmount
|
||||
|
||||
val newRewards = mutableListOf<String>()
|
||||
val oldLevel = playerLevel
|
||||
|
||||
// Check for level ups
|
||||
var xpForNextLevel = calculateXPForLevel(playerLevel)
|
||||
while (playerXP >= xpForNextLevel) {
|
||||
playerXP -= xpForNextLevel
|
||||
playerLevel++
|
||||
|
||||
// Check for new rewards at this level
|
||||
val levelRewards = checkLevelRewards(playerLevel)
|
||||
newRewards.addAll(levelRewards)
|
||||
|
||||
// Calculate XP needed for the next level
|
||||
xpForNextLevel = calculateXPForLevel(playerLevel)
|
||||
}
|
||||
|
||||
// Save progress if there were any changes
|
||||
if (oldLevel != playerLevel || newRewards.isNotEmpty()) {
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
return newRewards
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the player unlocked new rewards at the current level
|
||||
*/
|
||||
private fun checkLevelRewards(level: Int): List<String> {
|
||||
val newRewards = mutableListOf<String>()
|
||||
|
||||
// Check for theme unlocks
|
||||
when (level) {
|
||||
5 -> {
|
||||
if (unlockedThemes.add(THEME_NEON)) {
|
||||
newRewards.add("Unlocked Neon Theme!")
|
||||
}
|
||||
}
|
||||
10 -> {
|
||||
if (unlockedThemes.add(THEME_MONOCHROME)) {
|
||||
newRewards.add("Unlocked Monochrome Theme!")
|
||||
}
|
||||
}
|
||||
15 -> {
|
||||
if (unlockedThemes.add(THEME_RETRO)) {
|
||||
newRewards.add("Unlocked Retro Arcade Theme!")
|
||||
}
|
||||
}
|
||||
20 -> {
|
||||
if (unlockedThemes.add(THEME_MINIMALIST)) {
|
||||
newRewards.add("Unlocked Minimalist Theme!")
|
||||
}
|
||||
}
|
||||
25 -> {
|
||||
if (unlockedThemes.add(THEME_GALAXY)) {
|
||||
newRewards.add("Unlocked Galaxy Theme!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for block skin unlocks (start from skin 2 at level 7)
|
||||
when (level) {
|
||||
7 -> {
|
||||
if (unlockedBlocks.add("block_skin_2")) {
|
||||
newRewards.add("Unlocked Neon Block Skin!")
|
||||
}
|
||||
}
|
||||
14 -> {
|
||||
if (unlockedBlocks.add("block_skin_3")) {
|
||||
newRewards.add("Unlocked Retro Block Skin!")
|
||||
}
|
||||
}
|
||||
21 -> {
|
||||
if (unlockedBlocks.add("block_skin_4")) {
|
||||
newRewards.add("Unlocked Minimalist Block Skin!")
|
||||
}
|
||||
}
|
||||
28 -> {
|
||||
if (unlockedBlocks.add("block_skin_5")) {
|
||||
newRewards.add("Unlocked Galaxy Block Skin!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newRewards
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and unlock any rewards the player should have based on their current level
|
||||
* This ensures players don't miss unlocks if they level up multiple times at once
|
||||
*/
|
||||
private fun checkAllUnlocksForCurrentLevel() {
|
||||
// Check theme unlocks
|
||||
if (playerLevel >= 5) unlockedThemes.add(THEME_NEON)
|
||||
if (playerLevel >= 10) unlockedThemes.add(THEME_MONOCHROME)
|
||||
if (playerLevel >= 15) unlockedThemes.add(THEME_RETRO)
|
||||
if (playerLevel >= 20) unlockedThemes.add(THEME_MINIMALIST)
|
||||
if (playerLevel >= 25) unlockedThemes.add(THEME_GALAXY)
|
||||
|
||||
// Check block skin unlocks (start from skin 2 at level 7)
|
||||
// Skin 1 is default (added in loadProgress)
|
||||
if (playerLevel >= 7) unlockedBlocks.add("block_skin_2")
|
||||
if (playerLevel >= 14) unlockedBlocks.add("block_skin_3")
|
||||
if (playerLevel >= 21) unlockedBlocks.add("block_skin_4")
|
||||
if (playerLevel >= 28) unlockedBlocks.add("block_skin_5")
|
||||
|
||||
// Save any newly unlocked items
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new progression session
|
||||
*/
|
||||
fun startNewSession() {
|
||||
sessionXPGained = 0
|
||||
|
||||
// Ensure all appropriate unlocks are available
|
||||
checkAllUnlocksForCurrentLevel()
|
||||
}
|
||||
|
||||
// Getters
|
||||
fun getPlayerLevel(): Int = playerLevel
|
||||
fun getCurrentXP(): Long = playerXP
|
||||
fun getXPForNextLevel(): Long = calculateXPForLevel(playerLevel)
|
||||
fun getSessionXPGained(): Long = sessionXPGained
|
||||
fun getUnlockedThemes(): Set<String> = unlockedThemes.toSet()
|
||||
fun getUnlockedBlocks(): Set<String> = unlockedBlocks.toSet()
|
||||
fun getUnlockedBadges(): Set<String> = unlockedBadges.toSet()
|
||||
|
||||
/**
|
||||
* Check if a specific theme is unlocked
|
||||
*/
|
||||
fun isThemeUnlocked(themeId: String): Boolean {
|
||||
return unlockedThemes.contains(themeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Award a badge to the player
|
||||
*/
|
||||
fun awardBadge(badgeId: String): Boolean {
|
||||
val newlyAwarded = unlockedBadges.add(badgeId)
|
||||
if (newlyAwarded) {
|
||||
saveProgress()
|
||||
}
|
||||
return newlyAwarded
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all player progression data
|
||||
*/
|
||||
fun resetProgress() {
|
||||
playerLevel = 1
|
||||
playerXP = 0
|
||||
totalXPEarned = 0
|
||||
|
||||
unlockedThemes.clear()
|
||||
unlockedBlocks.clear()
|
||||
unlockedBadges.clear()
|
||||
|
||||
// Add default theme
|
||||
unlockedThemes.add(THEME_CLASSIC)
|
||||
|
||||
// Add default block skin (Level 1)
|
||||
unlockedBlocks.add("block_skin_1")
|
||||
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "mintris_progression"
|
||||
private const val KEY_PLAYER_LEVEL = "player_level"
|
||||
private const val KEY_PLAYER_XP = "player_xp"
|
||||
private const val KEY_TOTAL_XP_EARNED = "total_xp_earned"
|
||||
private const val KEY_UNLOCKED_THEMES = "unlocked_themes"
|
||||
private const val KEY_UNLOCKED_BLOCKS = "unlocked_blocks"
|
||||
private const val KEY_UNLOCKED_BADGES = "unlocked_badges"
|
||||
private const val KEY_SELECTED_THEME = "selected_theme"
|
||||
private const val KEY_SELECTED_BLOCK_SKIN = "selected_block_skin"
|
||||
|
||||
// XP constants
|
||||
private const val BASE_XP = 3000L
|
||||
private const val XP_CURVE_FACTOR = 2.0
|
||||
private const val LEVEL_MULTIPLIER = 0.03
|
||||
private const val XP_PER_LINE = 40L
|
||||
private const val TETRIS_XP_BONUS = 150L
|
||||
private const val PERFECT_CLEAR_XP_BONUS = 300L
|
||||
private const val TIME_XP_PER_MINUTE = 20L
|
||||
|
||||
// Theme constants
|
||||
const val THEME_CLASSIC = "theme_classic"
|
||||
const val THEME_NEON = "theme_neon"
|
||||
const val THEME_MONOCHROME = "theme_monochrome"
|
||||
const val THEME_RETRO = "theme_retro"
|
||||
const val THEME_MINIMALIST = "theme_minimalist"
|
||||
const val THEME_GALAXY = "theme_galaxy"
|
||||
|
||||
// Map of themes to required levels
|
||||
val THEME_REQUIRED_LEVELS = mapOf(
|
||||
THEME_CLASSIC to 1,
|
||||
THEME_NEON to 5,
|
||||
THEME_MONOCHROME to 10,
|
||||
THEME_RETRO to 15,
|
||||
THEME_MINIMALIST to 20,
|
||||
THEME_GALAXY to 25
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required level for a specific theme
|
||||
*/
|
||||
fun getRequiredLevelForTheme(themeId: String): Int {
|
||||
return THEME_REQUIRED_LEVELS[themeId] ?: 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected block skin
|
||||
*/
|
||||
fun setSelectedBlockSkin(skinId: String) {
|
||||
if (unlockedBlocks.contains(skinId)) {
|
||||
prefs.edit().putString(KEY_SELECTED_BLOCK_SKIN, skinId).commit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected block skin
|
||||
*/
|
||||
fun getSelectedBlockSkin(): String {
|
||||
return prefs.getString(KEY_SELECTED_BLOCK_SKIN, "block_skin_1") ?: "block_skin_1"
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected theme
|
||||
*/
|
||||
fun setSelectedTheme(themeId: String) {
|
||||
if (unlockedThemes.contains(themeId)) {
|
||||
prefs.edit().putString(KEY_SELECTED_THEME, themeId).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected theme
|
||||
*/
|
||||
fun getSelectedTheme(): String {
|
||||
return prefs.getString(KEY_SELECTED_THEME, THEME_CLASSIC) ?: THEME_CLASSIC
|
||||
}
|
||||
}
|
196
app/src/main/java/com/pixelmintdrop/model/StatsManager.kt
Normal file
196
app/src/main/java/com/pixelmintdrop/model/StatsManager.kt
Normal file
|
@ -0,0 +1,196 @@
|
|||
package com.mintris.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
class StatsManager(context: Context) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Lifetime stats
|
||||
private var totalGames: Int = 0
|
||||
private var totalScore: Long = 0
|
||||
private var totalLines: Int = 0
|
||||
private var totalPieces: Int = 0
|
||||
private var totalTime: Long = 0
|
||||
private var maxLevel: Int = 0
|
||||
private var maxScore: Int = 0
|
||||
private var maxLines: Int = 0
|
||||
|
||||
// Line clear stats (lifetime)
|
||||
private var totalSingles: Int = 0
|
||||
private var totalDoubles: Int = 0
|
||||
private var totalTriples: Int = 0
|
||||
private var totalTetrises: Int = 0
|
||||
|
||||
// Session stats
|
||||
private var sessionScore: Int = 0
|
||||
private var sessionLines: Int = 0
|
||||
private var sessionPieces: Int = 0
|
||||
private var sessionTime: Long = 0
|
||||
private var sessionLevel: Int = 0
|
||||
|
||||
// Line clear stats (session)
|
||||
private var sessionSingles: Int = 0
|
||||
private var sessionDoubles: Int = 0
|
||||
private var sessionTriples: Int = 0
|
||||
private var sessionTetrises: Int = 0
|
||||
|
||||
init {
|
||||
loadStats()
|
||||
}
|
||||
|
||||
private fun loadStats() {
|
||||
totalGames = prefs.getInt(KEY_TOTAL_GAMES, 0)
|
||||
totalScore = prefs.getLong(KEY_TOTAL_SCORE, 0)
|
||||
totalLines = prefs.getInt(KEY_TOTAL_LINES, 0)
|
||||
totalPieces = prefs.getInt(KEY_TOTAL_PIECES, 0)
|
||||
totalTime = prefs.getLong(KEY_TOTAL_TIME, 0)
|
||||
maxLevel = prefs.getInt(KEY_MAX_LEVEL, 0)
|
||||
maxScore = prefs.getInt(KEY_MAX_SCORE, 0)
|
||||
maxLines = prefs.getInt(KEY_MAX_LINES, 0)
|
||||
|
||||
// Load line clear stats
|
||||
totalSingles = prefs.getInt(KEY_TOTAL_SINGLES, 0)
|
||||
totalDoubles = prefs.getInt(KEY_TOTAL_DOUBLES, 0)
|
||||
totalTriples = prefs.getInt(KEY_TOTAL_TRIPLES, 0)
|
||||
totalTetrises = prefs.getInt(KEY_TOTAL_TETRISES, 0)
|
||||
}
|
||||
|
||||
private fun saveStats() {
|
||||
prefs.edit()
|
||||
.putInt(KEY_TOTAL_GAMES, totalGames)
|
||||
.putLong(KEY_TOTAL_SCORE, totalScore)
|
||||
.putInt(KEY_TOTAL_LINES, totalLines)
|
||||
.putInt(KEY_TOTAL_PIECES, totalPieces)
|
||||
.putLong(KEY_TOTAL_TIME, totalTime)
|
||||
.putInt(KEY_MAX_LEVEL, maxLevel)
|
||||
.putInt(KEY_MAX_SCORE, maxScore)
|
||||
.putInt(KEY_MAX_LINES, maxLines)
|
||||
.putInt(KEY_TOTAL_SINGLES, totalSingles)
|
||||
.putInt(KEY_TOTAL_DOUBLES, totalDoubles)
|
||||
.putInt(KEY_TOTAL_TRIPLES, totalTriples)
|
||||
.putInt(KEY_TOTAL_TETRISES, totalTetrises)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun startNewSession() {
|
||||
sessionScore = 0
|
||||
sessionLines = 0
|
||||
sessionPieces = 0
|
||||
sessionTime = 0
|
||||
sessionLevel = 0
|
||||
sessionSingles = 0
|
||||
sessionDoubles = 0
|
||||
sessionTriples = 0
|
||||
sessionTetrises = 0
|
||||
}
|
||||
|
||||
fun updateSessionStats(score: Int, lines: Int, pieces: Int, time: Long, level: Int) {
|
||||
sessionScore = score
|
||||
sessionLines = lines
|
||||
sessionPieces = pieces
|
||||
sessionTime = time
|
||||
sessionLevel = level
|
||||
}
|
||||
|
||||
fun recordLineClear(lineCount: Int) {
|
||||
when (lineCount) {
|
||||
1 -> {
|
||||
sessionSingles++
|
||||
totalSingles++
|
||||
}
|
||||
2 -> {
|
||||
sessionDoubles++
|
||||
totalDoubles++
|
||||
}
|
||||
3 -> {
|
||||
sessionTriples++
|
||||
totalTriples++
|
||||
}
|
||||
4 -> {
|
||||
sessionTetrises++
|
||||
totalTetrises++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun endSession() {
|
||||
totalGames++
|
||||
totalScore += sessionScore
|
||||
totalLines += sessionLines
|
||||
totalPieces += sessionPieces
|
||||
totalTime += sessionTime
|
||||
|
||||
if (sessionLevel > maxLevel) maxLevel = sessionLevel
|
||||
if (sessionScore > maxScore) maxScore = sessionScore
|
||||
if (sessionLines > maxLines) maxLines = sessionLines
|
||||
|
||||
saveStats()
|
||||
}
|
||||
|
||||
// Getters for lifetime stats
|
||||
fun getTotalGames(): Int = totalGames
|
||||
fun getTotalScore(): Long = totalScore
|
||||
fun getTotalLines(): Int = totalLines
|
||||
fun getTotalPieces(): Int = totalPieces
|
||||
fun getTotalTime(): Long = totalTime
|
||||
fun getMaxLevel(): Int = maxLevel
|
||||
fun getMaxScore(): Int = maxScore
|
||||
fun getMaxLines(): Int = maxLines
|
||||
|
||||
// Getters for line clear stats (lifetime)
|
||||
fun getTotalSingles(): Int = totalSingles
|
||||
fun getTotalDoubles(): Int = totalDoubles
|
||||
fun getTotalTriples(): Int = totalTriples
|
||||
fun getTotalTetrises(): Int = totalTetrises
|
||||
|
||||
// Getters for session stats
|
||||
fun getSessionScore(): Int = sessionScore
|
||||
fun getSessionLines(): Int = sessionLines
|
||||
fun getSessionPieces(): Int = sessionPieces
|
||||
fun getSessionTime(): Long = sessionTime
|
||||
fun getSessionLevel(): Int = sessionLevel
|
||||
|
||||
// Getters for line clear stats (session)
|
||||
fun getSessionSingles(): Int = sessionSingles
|
||||
fun getSessionDoubles(): Int = sessionDoubles
|
||||
fun getSessionTriples(): Int = sessionTriples
|
||||
fun getSessionTetrises(): Int = sessionTetrises
|
||||
|
||||
fun resetStats() {
|
||||
// Reset all lifetime stats
|
||||
totalGames = 0
|
||||
totalScore = 0
|
||||
totalLines = 0
|
||||
totalPieces = 0
|
||||
totalTime = 0
|
||||
maxLevel = 0
|
||||
maxScore = 0
|
||||
maxLines = 0
|
||||
|
||||
// Reset line clear stats
|
||||
totalSingles = 0
|
||||
totalDoubles = 0
|
||||
totalTriples = 0
|
||||
totalTetrises = 0
|
||||
|
||||
// Save the reset stats
|
||||
saveStats()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "mintris_stats"
|
||||
private const val KEY_TOTAL_GAMES = "total_games"
|
||||
private const val KEY_TOTAL_SCORE = "total_score"
|
||||
private const val KEY_TOTAL_LINES = "total_lines"
|
||||
private const val KEY_TOTAL_PIECES = "total_pieces"
|
||||
private const val KEY_TOTAL_TIME = "total_time"
|
||||
private const val KEY_MAX_LEVEL = "max_level"
|
||||
private const val KEY_MAX_SCORE = "max_score"
|
||||
private const val KEY_MAX_LINES = "max_lines"
|
||||
private const val KEY_TOTAL_SINGLES = "total_singles"
|
||||
private const val KEY_TOTAL_DOUBLES = "total_doubles"
|
||||
private const val KEY_TOTAL_TRIPLES = "total_triples"
|
||||
private const val KEY_TOTAL_TETRISES = "total_tetrises"
|
||||
}
|
||||
}
|
260
app/src/main/java/com/pixelmintdrop/model/Tetromino.kt
Normal file
260
app/src/main/java/com/pixelmintdrop/model/Tetromino.kt
Normal file
|
@ -0,0 +1,260 @@
|
|||
package com.mintris.model
|
||||
|
||||
/**
|
||||
* Represents a Tetris piece (Tetromino)
|
||||
*/
|
||||
enum class TetrominoType {
|
||||
I, J, L, O, S, T, Z
|
||||
}
|
||||
|
||||
class Tetromino(val type: TetrominoType) {
|
||||
|
||||
// Each tetromino has 4 rotations (0, 90, 180, 270 degrees)
|
||||
private val blocks: Array<Array<BooleanArray>> = getBlocks(type)
|
||||
private var currentRotation = 0
|
||||
|
||||
// Current position in the game grid
|
||||
var x = 0
|
||||
var y = 0
|
||||
|
||||
/**
|
||||
* Get the current shape of the tetromino based on rotation
|
||||
*/
|
||||
fun getCurrentShape(): Array<BooleanArray> {
|
||||
return blocks[currentRotation]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the width of the current tetromino shape
|
||||
*/
|
||||
fun getWidth(): Int {
|
||||
return blocks[currentRotation][0].size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the height of the current tetromino shape
|
||||
*/
|
||||
fun getHeight(): Int {
|
||||
return blocks[currentRotation].size
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the tetromino clockwise
|
||||
*/
|
||||
fun rotateClockwise() {
|
||||
currentRotation = (currentRotation + 1) % 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the tetromino counter-clockwise
|
||||
*/
|
||||
fun rotateCounterClockwise() {
|
||||
currentRotation = (currentRotation + 3) % 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tetromino's block exists at the given coordinates
|
||||
*/
|
||||
fun isBlockAt(blockX: Int, blockY: Int): Boolean {
|
||||
val shape = blocks[currentRotation]
|
||||
return if (blockY >= 0 && blockY < shape.size &&
|
||||
blockX >= 0 && blockX < shape[blockY].size) {
|
||||
shape[blockY][blockX]
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get the block patterns for each tetromino type and all its rotations
|
||||
*/
|
||||
private fun getBlocks(type: TetrominoType): Array<Array<BooleanArray>> {
|
||||
return when (type) {
|
||||
TetrominoType.I -> arrayOf(
|
||||
// Rotation 0°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, false, false),
|
||||
booleanArrayOf(true, true, true, true),
|
||||
booleanArrayOf(false, false, false, false),
|
||||
booleanArrayOf(false, false, false, false)
|
||||
),
|
||||
// Rotation 90°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, true, false),
|
||||
booleanArrayOf(false, false, true, false),
|
||||
booleanArrayOf(false, false, true, false),
|
||||
booleanArrayOf(false, false, true, false)
|
||||
),
|
||||
// Rotation 180°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, false, false),
|
||||
booleanArrayOf(false, false, false, false),
|
||||
booleanArrayOf(true, true, true, true),
|
||||
booleanArrayOf(false, false, false, false)
|
||||
),
|
||||
// Rotation 270°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false, false),
|
||||
booleanArrayOf(false, true, false, false),
|
||||
booleanArrayOf(false, true, false, false),
|
||||
booleanArrayOf(false, true, false, false)
|
||||
)
|
||||
)
|
||||
TetrominoType.J -> arrayOf(
|
||||
// Rotation 0°
|
||||
arrayOf(
|
||||
booleanArrayOf(true, false, false),
|
||||
booleanArrayOf(true, true, true),
|
||||
booleanArrayOf(false, false, false)
|
||||
),
|
||||
// Rotation 90°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, true),
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(false, true, false)
|
||||
),
|
||||
// Rotation 180°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, false),
|
||||
booleanArrayOf(true, true, true),
|
||||
booleanArrayOf(false, false, true)
|
||||
),
|
||||
// Rotation 270°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(true, true, false)
|
||||
)
|
||||
)
|
||||
TetrominoType.L -> arrayOf(
|
||||
// Rotation 0°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, true),
|
||||
booleanArrayOf(true, true, true),
|
||||
booleanArrayOf(false, false, false)
|
||||
),
|
||||
// Rotation 90°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(false, true, true)
|
||||
),
|
||||
// Rotation 180°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, false),
|
||||
booleanArrayOf(true, true, true),
|
||||
booleanArrayOf(true, false, false)
|
||||
),
|
||||
// Rotation 270°
|
||||
arrayOf(
|
||||
booleanArrayOf(true, true, false),
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(false, true, false)
|
||||
)
|
||||
)
|
||||
TetrominoType.O -> arrayOf(
|
||||
// All rotations are the same for O
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, false, false, false)
|
||||
),
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, false, false, false)
|
||||
),
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, false, false, false)
|
||||
),
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, true, true, false),
|
||||
booleanArrayOf(false, false, false, false)
|
||||
)
|
||||
)
|
||||
TetrominoType.S -> arrayOf(
|
||||
// Rotation 0°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, true),
|
||||
booleanArrayOf(true, true, false),
|
||||
booleanArrayOf(false, false, false)
|
||||
),
|
||||
// Rotation 90°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(false, true, true),
|
||||
booleanArrayOf(false, false, true)
|
||||
),
|
||||
// Rotation 180°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, false),
|
||||
booleanArrayOf(false, true, true),
|
||||
booleanArrayOf(true, true, false)
|
||||
),
|
||||
// Rotation 270°
|
||||
arrayOf(
|
||||
booleanArrayOf(true, false, false),
|
||||
booleanArrayOf(true, true, false),
|
||||
booleanArrayOf(false, true, false)
|
||||
)
|
||||
)
|
||||
TetrominoType.T -> arrayOf(
|
||||
// Rotation 0°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(true, true, true),
|
||||
booleanArrayOf(false, false, false)
|
||||
),
|
||||
// Rotation 90°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(false, true, true),
|
||||
booleanArrayOf(false, true, false)
|
||||
),
|
||||
// Rotation 180°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, false),
|
||||
booleanArrayOf(true, true, true),
|
||||
booleanArrayOf(false, true, false)
|
||||
),
|
||||
// Rotation 270°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(true, true, false),
|
||||
booleanArrayOf(false, true, false)
|
||||
)
|
||||
)
|
||||
TetrominoType.Z -> arrayOf(
|
||||
// Rotation 0°
|
||||
arrayOf(
|
||||
booleanArrayOf(true, true, false),
|
||||
booleanArrayOf(false, true, true),
|
||||
booleanArrayOf(false, false, false)
|
||||
),
|
||||
// Rotation 90°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, true),
|
||||
booleanArrayOf(false, true, true),
|
||||
booleanArrayOf(false, true, false)
|
||||
),
|
||||
// Rotation 180°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, false, false),
|
||||
booleanArrayOf(true, true, false),
|
||||
booleanArrayOf(false, true, true)
|
||||
),
|
||||
// Rotation 270°
|
||||
arrayOf(
|
||||
booleanArrayOf(false, true, false),
|
||||
booleanArrayOf(true, true, false),
|
||||
booleanArrayOf(true, false, false)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
433
app/src/main/java/com/pixelmintdrop/ui/BlockSkinSelector.kt
Normal file
433
app/src/main/java/com/pixelmintdrop/ui/BlockSkinSelector.kt
Normal file
|
@ -0,0 +1,433 @@
|
|||
package com.mintris.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.GridLayout
|
||||
import android.widget.TextView
|
||||
import androidx.cardview.widget.CardView
|
||||
import com.mintris.R
|
||||
import com.mintris.model.PlayerProgressionManager
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
|
||||
/**
|
||||
* UI component for selecting block skins
|
||||
*/
|
||||
class BlockSkinSelector @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val skinsGrid: GridLayout
|
||||
private val availableSkinsLabel: TextView
|
||||
|
||||
// Callback when a block skin is selected
|
||||
var onBlockSkinSelected: ((String) -> Unit)? = null
|
||||
|
||||
// Currently selected block skin (persisted)
|
||||
private var selectedSkin: String = "block_skin_1"
|
||||
|
||||
// Block skin cards map (skinId -> CardView)
|
||||
private val skinCards = mutableMapOf<String, CardView>()
|
||||
// Ordered list of skin IDs for navigation
|
||||
private val skinIdList = mutableListOf<String>()
|
||||
// Currently focused skin ID (for gamepad navigation within the selector)
|
||||
private var focusedSkinId: String? = null
|
||||
// Index of the currently focused skin in skinIdList
|
||||
private var focusedIndex: Int = -1
|
||||
// Flag indicating if the entire selector component has focus from the main menu
|
||||
private var hasComponentFocus: Boolean = false
|
||||
|
||||
// Player level for determining what should be unlocked
|
||||
private var playerLevel: Int = 1
|
||||
|
||||
init {
|
||||
// Inflate the layout
|
||||
LayoutInflater.from(context).inflate(R.layout.block_skin_selector, this, true)
|
||||
|
||||
// Get references to views
|
||||
skinsGrid = findViewById(R.id.skins_grid)
|
||||
availableSkinsLabel = findViewById(R.id.available_skins_label)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the block skin selector with unlocked skins
|
||||
*/
|
||||
fun updateBlockSkins(unlockedSkins: Set<String>, currentSkin: String, playerLevel: Int = 1) {
|
||||
// Store player level
|
||||
this.playerLevel = playerLevel
|
||||
|
||||
// Clear existing skin cards and ID list
|
||||
skinsGrid.removeAllViews()
|
||||
skinCards.clear()
|
||||
skinIdList.clear()
|
||||
|
||||
// Update selected skin and initial focus
|
||||
selectedSkin = currentSkin
|
||||
focusedSkinId = currentSkin
|
||||
focusedIndex = -1 // Reset index
|
||||
|
||||
// Get all possible skins and their details, sorted for consistent order
|
||||
val allSkins = getBlockSkins().entries.sortedWith(compareBy({ it.value.unlockLevel }, { it.value.displayName })).associate { it.key to it.value }
|
||||
|
||||
// Add skin cards to the grid and build ID list
|
||||
allSkins.forEach { (skinId, skinInfo) ->
|
||||
val isEffectivelyUnlocked = unlockedSkins.contains(skinId) || playerLevel >= skinInfo.unlockLevel
|
||||
val isSelected = skinId == selectedSkin
|
||||
|
||||
// Only add unlocked skins to the navigable list
|
||||
if (isEffectivelyUnlocked) {
|
||||
skinIdList.add(skinId)
|
||||
}
|
||||
|
||||
val skinCard = createBlockSkinCard(skinId, skinInfo, isEffectivelyUnlocked, isSelected)
|
||||
skinCards[skinId] = skinCard
|
||||
skinsGrid.addView(skinCard)
|
||||
|
||||
// Update focused index if this is the currently selected/focused skin
|
||||
if (isEffectivelyUnlocked && skinId == focusedSkinId) {
|
||||
focusedIndex = skinIdList.indexOf(skinId)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure focus index is valid if the previously focused skin is no longer available/unlocked
|
||||
if (focusedIndex == -1 && skinIdList.isNotEmpty()) {
|
||||
focusedIndex = 0
|
||||
focusedSkinId = skinIdList[0]
|
||||
}
|
||||
|
||||
// Apply initial focus highlight if the component has focus
|
||||
highlightFocusedCard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a card for a block skin
|
||||
*/
|
||||
private fun createBlockSkinCard(
|
||||
skinId: String,
|
||||
skinInfo: BlockSkinInfo,
|
||||
isUnlocked: Boolean,
|
||||
isSelected: Boolean
|
||||
): CardView {
|
||||
// Create the card
|
||||
val card = CardView(context).apply {
|
||||
id = View.generateViewId()
|
||||
radius = 12f
|
||||
cardElevation = if (isSelected) 8f else 2f
|
||||
useCompatPadding = true
|
||||
|
||||
// Set card background color based on skin
|
||||
setCardBackgroundColor(skinInfo.backgroundColor)
|
||||
|
||||
// Set card dimensions
|
||||
val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size)
|
||||
layoutParams = GridLayout.LayoutParams().apply {
|
||||
width = cardSize
|
||||
height = cardSize
|
||||
columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
|
||||
rowSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
|
||||
setMargins(8, 8, 8, 8)
|
||||
}
|
||||
|
||||
// Apply locked/selected state visuals (only opacity here)
|
||||
alpha = if (isUnlocked) 1.0f else 0.5f
|
||||
}
|
||||
|
||||
// Create block skin content container
|
||||
val container = FrameLayout(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
// Create the block skin preview
|
||||
val blockSkinPreview = View(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
// Set the background color
|
||||
setBackgroundColor(skinInfo.backgroundColor)
|
||||
}
|
||||
|
||||
// Add a label with the skin name
|
||||
val skinLabel = TextView(context).apply {
|
||||
text = skinInfo.displayName
|
||||
setTextColor(skinInfo.textColor)
|
||||
textSize = 14f
|
||||
textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||
|
||||
// Position at the bottom of the card
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL
|
||||
setMargins(4, 4, 4, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Add level requirement for locked skins
|
||||
val levelRequirement = TextView(context).apply {
|
||||
text = "Level ${skinInfo.unlockLevel}"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||
visibility = if (isUnlocked) View.GONE else View.VISIBLE
|
||||
|
||||
// Position at the center of the card
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
// Make text bold and more visible for better readability
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
setShadowLayer(3f, 1f, 1f, Color.BLACK)
|
||||
}
|
||||
|
||||
// Add a lock icon if the skin is locked
|
||||
val lockOverlay = View(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
// Add lock icon or visual indicator
|
||||
setBackgroundResource(R.drawable.lock_overlay)
|
||||
visibility = if (isUnlocked) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
// Add all elements to container
|
||||
container.addView(blockSkinPreview)
|
||||
container.addView(skinLabel)
|
||||
container.addView(lockOverlay)
|
||||
container.addView(levelRequirement)
|
||||
|
||||
// Add container to card
|
||||
card.addView(container)
|
||||
|
||||
// Set up click listener only for unlocked skins
|
||||
if (isUnlocked) {
|
||||
card.setOnClickListener {
|
||||
// Clicking directly selects the skin
|
||||
focusedSkinId = skinId
|
||||
focusedIndex = skinIdList.indexOf(skinId)
|
||||
confirmSelection() // Directly confirm click selection
|
||||
}
|
||||
}
|
||||
|
||||
return card
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for block skin information
|
||||
*/
|
||||
data class BlockSkinInfo(
|
||||
val displayName: String,
|
||||
val backgroundColor: Int,
|
||||
val textColor: Int,
|
||||
val unlockLevel: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Get all available block skins with their details
|
||||
*/
|
||||
private fun getBlockSkins(): Map<String, BlockSkinInfo> {
|
||||
return mapOf(
|
||||
"block_skin_1" to BlockSkinInfo(
|
||||
displayName = "Classic",
|
||||
backgroundColor = Color.BLACK,
|
||||
textColor = Color.WHITE,
|
||||
unlockLevel = 1
|
||||
),
|
||||
"block_skin_2" to BlockSkinInfo(
|
||||
displayName = "Neon",
|
||||
backgroundColor = Color.parseColor("#0D0221"),
|
||||
textColor = Color.parseColor("#FF00FF"),
|
||||
unlockLevel = 7
|
||||
),
|
||||
"block_skin_3" to BlockSkinInfo(
|
||||
displayName = "Retro",
|
||||
backgroundColor = Color.parseColor("#3F2832"),
|
||||
textColor = Color.parseColor("#FF5A5F"),
|
||||
unlockLevel = 14
|
||||
),
|
||||
"block_skin_4" to BlockSkinInfo(
|
||||
displayName = "Minimalist",
|
||||
backgroundColor = Color.WHITE,
|
||||
textColor = Color.BLACK,
|
||||
unlockLevel = 21
|
||||
),
|
||||
"block_skin_5" to BlockSkinInfo(
|
||||
displayName = "Galaxy",
|
||||
backgroundColor = Color.parseColor("#0B0C10"),
|
||||
textColor = Color.parseColor("#66FCF1"),
|
||||
unlockLevel = 28
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the entire component has focus (from the parent menu).
|
||||
*/
|
||||
fun setHasFocus(hasFocus: Boolean) {
|
||||
hasComponentFocus = hasFocus
|
||||
highlightFocusedCard() // Re-apply highlights
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the internal focus to the next available skin.
|
||||
*/
|
||||
fun focusNextItem() {
|
||||
if (!hasComponentFocus || skinIdList.isEmpty()) return
|
||||
|
||||
focusedIndex = (focusedIndex + 1) % skinIdList.size
|
||||
focusedSkinId = skinIdList[focusedIndex]
|
||||
highlightFocusedCard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the internal focus to the previous available skin.
|
||||
*/
|
||||
fun focusPreviousItem() {
|
||||
if (!hasComponentFocus || skinIdList.isEmpty()) return
|
||||
|
||||
focusedIndex = (focusedIndex - 1 + skinIdList.size) % skinIdList.size
|
||||
focusedSkinId = skinIdList[focusedIndex]
|
||||
highlightFocusedCard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the currently focused skin as the selected skin.
|
||||
* Triggers the onBlockSkinSelected callback.
|
||||
*/
|
||||
fun confirmSelection() {
|
||||
if (focusedSkinId == null || focusedSkinId == selectedSkin) {
|
||||
return // No change needed
|
||||
}
|
||||
|
||||
// Update the selected skin
|
||||
val newlySelectedSkin = focusedSkinId!!
|
||||
selectedSkin = newlySelectedSkin
|
||||
|
||||
// Update visual states
|
||||
highlightFocusedCard()
|
||||
|
||||
// Trigger the callback
|
||||
onBlockSkinSelected?.invoke(selectedSkin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visual highlight state of the skin cards based on
|
||||
* selection and internal focus.
|
||||
*/
|
||||
private fun highlightFocusedCard() {
|
||||
if (skinCards.isEmpty()) return
|
||||
|
||||
val focusColor = Color.YELLOW // Color for focused-but-not-selected
|
||||
val selectedColor = Color.WHITE // Color for selected (might be focused or not)
|
||||
|
||||
skinCards.forEach { (skinId, card) ->
|
||||
val skinInfo = getBlockSkins()[skinId] ?: return@forEach
|
||||
// Check unlock status based on the navigable list derived from level/unlocks
|
||||
val isUnlocked = skinIdList.contains(skinId)
|
||||
|
||||
if (!isUnlocked) {
|
||||
// Keep locked skins visually distinct
|
||||
card.alpha = 0.5f
|
||||
card.cardElevation = 2f
|
||||
card.background = null
|
||||
card.setCardBackgroundColor(skinInfo.backgroundColor)
|
||||
card.scaleX = 1.0f // Reset scale
|
||||
card.scaleY = 1.0f
|
||||
return@forEach
|
||||
}
|
||||
|
||||
// Reset unlocked cards first
|
||||
card.alpha = 1.0f
|
||||
card.cardElevation = 4f
|
||||
card.background = null
|
||||
card.setCardBackgroundColor(skinInfo.backgroundColor)
|
||||
card.scaleX = 1.0f
|
||||
card.scaleY = 1.0f
|
||||
|
||||
val isSelected = (skinId == selectedSkin)
|
||||
val isFocused = (hasComponentFocus && skinId == focusedSkinId)
|
||||
|
||||
var borderColor = Color.TRANSPARENT
|
||||
var borderWidth = 0
|
||||
var elevation = 4f
|
||||
var scale = 1.0f
|
||||
|
||||
if (isSelected) {
|
||||
borderColor = selectedColor
|
||||
borderWidth = 6 // Thick border for selected
|
||||
elevation = 12f // Higher elevation for selected
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
// Focused item gets a distinct border (unless it's also selected)
|
||||
if (!isSelected) {
|
||||
borderColor = focusColor
|
||||
borderWidth = 4 // Slightly thinner border for focused
|
||||
}
|
||||
elevation = 12f // Use high elevation for focus too
|
||||
scale = 1.1f // Scale up the focused item
|
||||
}
|
||||
|
||||
// Apply scale
|
||||
card.scaleX = scale
|
||||
card.scaleY = scale
|
||||
|
||||
// Apply border and elevation
|
||||
if (borderWidth > 0) {
|
||||
val gradientDrawable = GradientDrawable().apply {
|
||||
setColor(skinInfo.backgroundColor) // Use skin's background for the fill
|
||||
setStroke(borderWidth, borderColor)
|
||||
cornerRadius = 12f
|
||||
}
|
||||
card.background = gradientDrawable
|
||||
} else {
|
||||
card.background = null // Ensure no border if not selected/focused
|
||||
}
|
||||
card.cardElevation = elevation
|
||||
}
|
||||
}
|
||||
|
||||
// Keep selectNextBlockSkin temporarily for compatibility, but it shouldn't be called by MainActivity anymore
|
||||
fun selectNextBlockSkin() {
|
||||
val allSkins = getBlockSkins().keys.toList()
|
||||
val currentIndex = allSkins.indexOf(selectedSkin)
|
||||
if (currentIndex == -1) return
|
||||
|
||||
var nextIndex = (currentIndex + 1) % allSkins.size
|
||||
while (nextIndex != currentIndex) {
|
||||
val nextSkin = allSkins[nextIndex]
|
||||
val skinInfo = getBlockSkins()[nextSkin] ?: continue
|
||||
val isEffectivelyUnlocked = skinCards[nextSkin]?.alpha == 1.0f // Basic check based on alpha
|
||||
|| playerLevel >= skinInfo.unlockLevel
|
||||
|
||||
if (isEffectivelyUnlocked) {
|
||||
// This method now just sets the internal focus and confirms
|
||||
focusedSkinId = nextSkin
|
||||
focusedIndex = skinIdList.indexOf(nextSkin) // Update index based on navigable list
|
||||
if (focusedIndex == -1) { // If not found in navigable list, reset focus
|
||||
focusedIndex = 0
|
||||
focusedSkinId = if (skinIdList.isNotEmpty()) skinIdList[0] else null
|
||||
}
|
||||
confirmSelection() // Confirm the selection
|
||||
return
|
||||
}
|
||||
nextIndex = (nextIndex + 1) % allSkins.size
|
||||
}
|
||||
}
|
||||
}
|
67
app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt
Normal file
67
app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt
Normal file
|
@ -0,0 +1,67 @@
|
|||
package com.mintris.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Custom view for displaying the player's level in a fancy badge style
|
||||
*/
|
||||
class LevelBadge @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val badgePaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = 48f
|
||||
isFakeBoldText = true
|
||||
}
|
||||
|
||||
private var level = 1
|
||||
private var themeColor = Color.WHITE
|
||||
|
||||
fun setLevel(newLevel: Int) {
|
||||
level = newLevel
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setThemeColor(color: Int) {
|
||||
themeColor = color
|
||||
badgePaint.color = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
// Adjust text size based on view size
|
||||
textPaint.textSize = h * 0.6f
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
// Draw badge circle
|
||||
val radius = (width.coerceAtMost(height) / 2f) * 0.9f
|
||||
canvas.drawCircle(width / 2f, height / 2f, radius, badgePaint)
|
||||
|
||||
// Draw level text
|
||||
canvas.drawText(
|
||||
level.toString(),
|
||||
width / 2f,
|
||||
height / 2f + (textPaint.textSize / 3),
|
||||
textPaint
|
||||
)
|
||||
}
|
||||
}
|
314
app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt
Normal file
314
app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt
Normal file
|
@ -0,0 +1,314 @@
|
|||
package com.mintris.ui
|
||||
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.cardview.widget.CardView
|
||||
import com.mintris.R
|
||||
import com.mintris.model.PlayerProgressionManager
|
||||
|
||||
/**
|
||||
* Screen that displays player progression, XP gain, and unlocked rewards
|
||||
*/
|
||||
class ProgressionScreen @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
// UI components
|
||||
private val xpProgressBar: XPProgressBar
|
||||
private val xpGainText: TextView
|
||||
private val playerLevelText: TextView
|
||||
private val rewardsContainer: LinearLayout
|
||||
private val continueButton: TextView
|
||||
|
||||
// Current theme
|
||||
private var currentTheme: String = PlayerProgressionManager.THEME_CLASSIC
|
||||
|
||||
// Callback for when the player dismisses the screen
|
||||
var onContinue: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
|
||||
// Inflate the layout
|
||||
LayoutInflater.from(context).inflate(R.layout.progression_screen, this, true)
|
||||
|
||||
// Get references to views
|
||||
xpProgressBar = findViewById(R.id.xp_progress_bar)
|
||||
xpGainText = findViewById(R.id.xp_gain_text)
|
||||
playerLevelText = findViewById(R.id.player_level_text)
|
||||
rewardsContainer = findViewById(R.id.rewards_container)
|
||||
continueButton = findViewById(R.id.continue_button)
|
||||
|
||||
// Set up button click listener
|
||||
continueButton.setOnClickListener {
|
||||
onContinue?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display progression data and animate XP gain
|
||||
*/
|
||||
fun showProgress(
|
||||
progressionManager: PlayerProgressionManager,
|
||||
xpGained: Long,
|
||||
newRewards: List<String>,
|
||||
themeId: String = PlayerProgressionManager.THEME_CLASSIC
|
||||
) {
|
||||
// Update current theme
|
||||
currentTheme = themeId
|
||||
|
||||
// Hide rewards container initially if there are no new rewards
|
||||
rewardsContainer.visibility = if (newRewards.isEmpty()) View.GONE else View.INVISIBLE
|
||||
|
||||
// Set initial progress bar state
|
||||
val playerLevel = progressionManager.getPlayerLevel()
|
||||
val currentXP = progressionManager.getCurrentXP()
|
||||
val xpForNextLevel = progressionManager.getXPForNextLevel()
|
||||
|
||||
// Update texts
|
||||
playerLevelText.text = "Player Level: $playerLevel"
|
||||
xpGainText.text = "+$xpGained XP"
|
||||
|
||||
// Update level up text visibility
|
||||
val progressionTitle = findViewById<TextView>(R.id.progression_title)
|
||||
progressionTitle.visibility = if (newRewards.any { it.contains("Level") }) View.VISIBLE else View.GONE
|
||||
|
||||
// Start with initial animations
|
||||
AnimatorSet().apply {
|
||||
// Fade in the XP gain text
|
||||
val xpTextAnimator = ObjectAnimator.ofFloat(xpGainText, "alpha", 0f, 1f).apply {
|
||||
duration = 800
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
|
||||
// Set up the XP progress bar animation sequence
|
||||
val xpBarAnimator = ObjectAnimator.ofFloat(xpProgressBar, "alpha", 0f, 1f).apply {
|
||||
duration = 800
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
|
||||
// Play animations in sequence
|
||||
play(xpTextAnimator)
|
||||
play(xpBarAnimator).after(xpTextAnimator)
|
||||
start()
|
||||
}
|
||||
|
||||
// Set initial progress bar state
|
||||
xpProgressBar.setXPValues(playerLevel, currentXP - xpGained, xpForNextLevel)
|
||||
|
||||
// Animate the XP gain after a short delay
|
||||
postDelayed({
|
||||
xpProgressBar.animateXPGain(xpGained, playerLevel, currentXP, xpForNextLevel)
|
||||
}, 1000) // Increased delay to 1 second for better visual flow
|
||||
|
||||
// If there are new rewards, show them with animation after XP bar animation
|
||||
if (newRewards.isNotEmpty()) {
|
||||
// Create reward cards
|
||||
rewardsContainer.removeAllViews()
|
||||
newRewards.forEach { reward ->
|
||||
val rewardCard = createRewardCard(reward)
|
||||
rewardsContainer.addView(rewardCard)
|
||||
}
|
||||
|
||||
// Apply theme to newly created reward cards
|
||||
updateRewardCardColors(themeId)
|
||||
|
||||
// Show rewards with animation after XP bar animation
|
||||
postDelayed({
|
||||
rewardsContainer.visibility = View.VISIBLE
|
||||
|
||||
// Animate each reward card
|
||||
for (i in 0 until rewardsContainer.childCount) {
|
||||
val card = rewardsContainer.getChildAt(i)
|
||||
card.alpha = 0f
|
||||
card.translationY = 100f
|
||||
|
||||
// Stagger animation for each card
|
||||
card.animate()
|
||||
.alpha(1f)
|
||||
.translationY(0f)
|
||||
.setDuration(600) // Increased duration for smoother animation
|
||||
.setStartDelay((i * 200).toLong()) // Increased delay between cards
|
||||
.setInterpolator(OvershootInterpolator())
|
||||
.start()
|
||||
}
|
||||
}, 2500) // Increased delay to wait for XP bar animation to finish
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a card view to display a reward
|
||||
*/
|
||||
private fun createRewardCard(rewardText: String): CardView {
|
||||
val card = CardView(context).apply {
|
||||
radius = 8f
|
||||
cardElevation = 4f
|
||||
useCompatPadding = true
|
||||
|
||||
// Set background color based on current theme
|
||||
val backgroundColor = when (currentTheme) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
|
||||
else -> Color.BLACK
|
||||
}
|
||||
setCardBackgroundColor(backgroundColor)
|
||||
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
setMargins(16, 8, 16, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Add reward text
|
||||
val textView = TextView(context).apply {
|
||||
text = rewardText
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 18f
|
||||
setPadding(16, 16, 16, 16)
|
||||
textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||
// Add some visual styling
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
}
|
||||
|
||||
card.addView(textView)
|
||||
return card
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the current theme to the progression screen
|
||||
*/
|
||||
fun applyTheme(themeId: String) {
|
||||
currentTheme = themeId
|
||||
|
||||
// Get reference to the title text
|
||||
val progressionTitle = findViewById<TextView>(R.id.progression_title)
|
||||
val rewardsTitle = findViewById<TextView>(R.id.rewards_title)
|
||||
|
||||
// Theme color for XP progress bar level badge
|
||||
val xpThemeColor: Int
|
||||
|
||||
// Apply theme colors based on theme ID
|
||||
when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> {
|
||||
// Default black theme
|
||||
setBackgroundColor(Color.BLACK)
|
||||
progressionTitle.setTextColor(Color.WHITE)
|
||||
playerLevelText.setTextColor(Color.WHITE)
|
||||
xpGainText.setTextColor(Color.WHITE)
|
||||
continueButton.setTextColor(Color.WHITE)
|
||||
rewardsTitle.setTextColor(Color.WHITE)
|
||||
xpThemeColor = Color.WHITE
|
||||
}
|
||||
PlayerProgressionManager.THEME_NEON -> {
|
||||
// Neon theme with dark purple background
|
||||
setBackgroundColor(Color.parseColor("#0D0221"))
|
||||
progressionTitle.setTextColor(Color.parseColor("#FF00FF"))
|
||||
playerLevelText.setTextColor(Color.parseColor("#FF00FF"))
|
||||
xpGainText.setTextColor(Color.WHITE)
|
||||
continueButton.setTextColor(Color.parseColor("#FF00FF"))
|
||||
rewardsTitle.setTextColor(Color.WHITE)
|
||||
xpThemeColor = Color.parseColor("#FF00FF")
|
||||
}
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> {
|
||||
// Monochrome dark gray
|
||||
setBackgroundColor(Color.parseColor("#1A1A1A"))
|
||||
progressionTitle.setTextColor(Color.LTGRAY)
|
||||
playerLevelText.setTextColor(Color.LTGRAY)
|
||||
xpGainText.setTextColor(Color.WHITE)
|
||||
continueButton.setTextColor(Color.LTGRAY)
|
||||
rewardsTitle.setTextColor(Color.WHITE)
|
||||
xpThemeColor = Color.LTGRAY
|
||||
}
|
||||
PlayerProgressionManager.THEME_RETRO -> {
|
||||
// Retro arcade theme
|
||||
setBackgroundColor(Color.parseColor("#3F2832"))
|
||||
progressionTitle.setTextColor(Color.parseColor("#FF5A5F"))
|
||||
playerLevelText.setTextColor(Color.parseColor("#FF5A5F"))
|
||||
xpGainText.setTextColor(Color.WHITE)
|
||||
continueButton.setTextColor(Color.parseColor("#FF5A5F"))
|
||||
rewardsTitle.setTextColor(Color.WHITE)
|
||||
xpThemeColor = Color.parseColor("#FF5A5F")
|
||||
}
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> {
|
||||
// Minimalist white theme
|
||||
setBackgroundColor(Color.WHITE)
|
||||
progressionTitle.setTextColor(Color.BLACK)
|
||||
playerLevelText.setTextColor(Color.BLACK)
|
||||
xpGainText.setTextColor(Color.BLACK)
|
||||
continueButton.setTextColor(Color.BLACK)
|
||||
rewardsTitle.setTextColor(Color.BLACK)
|
||||
xpThemeColor = Color.BLACK
|
||||
}
|
||||
PlayerProgressionManager.THEME_GALAXY -> {
|
||||
// Galaxy dark blue theme
|
||||
setBackgroundColor(Color.parseColor("#0B0C10"))
|
||||
progressionTitle.setTextColor(Color.parseColor("#66FCF1"))
|
||||
playerLevelText.setTextColor(Color.parseColor("#66FCF1"))
|
||||
xpGainText.setTextColor(Color.WHITE)
|
||||
continueButton.setTextColor(Color.parseColor("#66FCF1"))
|
||||
rewardsTitle.setTextColor(Color.WHITE)
|
||||
xpThemeColor = Color.parseColor("#66FCF1")
|
||||
}
|
||||
else -> {
|
||||
// Default fallback
|
||||
setBackgroundColor(Color.BLACK)
|
||||
progressionTitle.setTextColor(Color.WHITE)
|
||||
playerLevelText.setTextColor(Color.WHITE)
|
||||
xpGainText.setTextColor(Color.WHITE)
|
||||
continueButton.setTextColor(Color.WHITE)
|
||||
rewardsTitle.setTextColor(Color.WHITE)
|
||||
xpThemeColor = Color.WHITE
|
||||
}
|
||||
}
|
||||
|
||||
// Update XP progress bar theme color
|
||||
xpProgressBar.setThemeColor(xpThemeColor)
|
||||
|
||||
// Update reward card colors
|
||||
updateRewardCardColors(themeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update colors of existing reward cards to match the theme
|
||||
*/
|
||||
private fun updateRewardCardColors(themeId: String) {
|
||||
val backgroundColor = when (themeId) {
|
||||
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
|
||||
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
|
||||
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
|
||||
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832")
|
||||
PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE
|
||||
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
|
||||
else -> Color.BLACK
|
||||
}
|
||||
|
||||
for (i in 0 until rewardsContainer.childCount) {
|
||||
val card = rewardsContainer.getChildAt(i) as? CardView
|
||||
card?.setCardBackgroundColor(backgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to handle continue action via gamepad
|
||||
*/
|
||||
fun performContinue() {
|
||||
continueButton.performClick()
|
||||
}
|
||||
}
|
415
app/src/main/java/com/pixelmintdrop/ui/ThemeSelector.kt
Normal file
415
app/src/main/java/com/pixelmintdrop/ui/ThemeSelector.kt
Normal file
|
@ -0,0 +1,415 @@
|
|||
package com.mintris.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.GridLayout
|
||||
import android.widget.TextView
|
||||
import androidx.cardview.widget.CardView
|
||||
import com.mintris.R
|
||||
import com.mintris.model.PlayerProgressionManager
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* UI component for selecting game themes
|
||||
*/
|
||||
class ThemeSelector @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val themesGrid: GridLayout
|
||||
private val availableThemesLabel: TextView
|
||||
|
||||
// Callback when a theme is selected
|
||||
var onThemeSelected: ((String) -> Unit)? = null
|
||||
|
||||
// Currently selected theme (persisted)
|
||||
private var selectedTheme: String = PlayerProgressionManager.THEME_CLASSIC
|
||||
|
||||
// Theme cards map (themeId -> CardView)
|
||||
private val themeCards = mutableMapOf<String, CardView>()
|
||||
// Ordered list of theme IDs for navigation
|
||||
private val themeIdList = mutableListOf<String>()
|
||||
// Currently focused theme ID (for gamepad navigation within the selector)
|
||||
private var focusedThemeId: String? = null
|
||||
// Index of the currently focused theme in themeIdList
|
||||
private var focusedIndex: Int = -1
|
||||
// Flag indicating if the entire selector component has focus from the main menu
|
||||
private var hasComponentFocus: Boolean = false
|
||||
|
||||
init {
|
||||
// Inflate the layout
|
||||
LayoutInflater.from(context).inflate(R.layout.theme_selector, this, true)
|
||||
|
||||
// Get references to views
|
||||
themesGrid = findViewById(R.id.themes_grid)
|
||||
availableThemesLabel = findViewById(R.id.available_themes_label)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the theme selector with unlocked themes
|
||||
*/
|
||||
fun updateThemes(unlockedThemes: Set<String>, currentTheme: String) {
|
||||
// Clear existing theme cards and ID list
|
||||
themesGrid.removeAllViews()
|
||||
themeCards.clear()
|
||||
themeIdList.clear()
|
||||
|
||||
// Update selected theme
|
||||
selectedTheme = currentTheme
|
||||
focusedThemeId = currentTheme // Initially focus the selected theme
|
||||
focusedIndex = -1 // Reset index
|
||||
|
||||
// Get all possible themes and their details, sorted for consistent order
|
||||
val allThemes = getThemes().entries.sortedWith(compareBy({ it.value.unlockLevel }, { it.value.displayName })).associate { it.key to it.value }
|
||||
|
||||
// Add theme cards to the grid and build the ID list
|
||||
allThemes.forEach { (themeId, themeInfo) ->
|
||||
val isUnlocked = unlockedThemes.contains(themeId)
|
||||
val isSelected = themeId == selectedTheme
|
||||
|
||||
// Only add unlocked themes to the navigable list
|
||||
if (isUnlocked) {
|
||||
themeIdList.add(themeId)
|
||||
}
|
||||
|
||||
val themeCard = createThemeCard(themeId, themeInfo, isUnlocked, isSelected)
|
||||
themeCards[themeId] = themeCard
|
||||
themesGrid.addView(themeCard)
|
||||
|
||||
// Update focused index if this is the currently selected/focused theme
|
||||
if (isUnlocked && themeId == focusedThemeId) {
|
||||
focusedIndex = themeIdList.indexOf(themeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure focus index is valid if the previously focused theme is no longer available/unlocked
|
||||
if (focusedIndex == -1 && themeIdList.isNotEmpty()) {
|
||||
focusedIndex = 0
|
||||
focusedThemeId = themeIdList[0]
|
||||
}
|
||||
|
||||
// Apply initial focus highlight if the component has focus
|
||||
highlightFocusedCard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a card for a theme
|
||||
*/
|
||||
private fun createThemeCard(
|
||||
themeId: String,
|
||||
themeInfo: ThemeInfo,
|
||||
isUnlocked: Boolean,
|
||||
isSelected: Boolean
|
||||
): CardView {
|
||||
// Create the card
|
||||
val card = CardView(context).apply {
|
||||
id = View.generateViewId()
|
||||
radius = 12f
|
||||
cardElevation = if (isSelected) 8f else 2f
|
||||
useCompatPadding = true
|
||||
|
||||
// Set card background color based on theme
|
||||
setCardBackgroundColor(themeInfo.primaryColor)
|
||||
|
||||
// Set card dimensions
|
||||
val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size)
|
||||
layoutParams = GridLayout.LayoutParams().apply {
|
||||
width = cardSize
|
||||
height = cardSize
|
||||
columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
|
||||
rowSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
|
||||
setMargins(8, 8, 8, 8)
|
||||
}
|
||||
|
||||
// Apply locked/selected state visuals
|
||||
alpha = if (isUnlocked) 1.0f else 0.5f
|
||||
}
|
||||
|
||||
// Create theme content container
|
||||
val container = FrameLayout(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
// Create the theme preview
|
||||
val themePreview = View(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
// Set the background color
|
||||
setBackgroundColor(themeInfo.primaryColor)
|
||||
}
|
||||
|
||||
// Add a label with the theme name
|
||||
val themeLabel = TextView(context).apply {
|
||||
text = themeInfo.displayName
|
||||
setTextColor(themeInfo.textColor)
|
||||
textSize = 14f
|
||||
textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||
|
||||
// Position at the bottom of the card
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL
|
||||
setMargins(4, 4, 4, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Add level requirement for locked themes
|
||||
val levelRequirement = TextView(context).apply {
|
||||
text = "Level ${themeInfo.unlockLevel}"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||
visibility = if (isUnlocked) View.GONE else View.VISIBLE
|
||||
|
||||
// Position at the center of the card
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
// Make text bold and more visible for better readability
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
setShadowLayer(3f, 1f, 1f, Color.BLACK)
|
||||
}
|
||||
|
||||
// Add a lock icon if the theme is locked
|
||||
val lockOverlay = View(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
// Add lock icon or visual indicator
|
||||
setBackgroundResource(R.drawable.lock_overlay)
|
||||
visibility = if (isUnlocked) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
// Add all elements to container
|
||||
container.addView(themePreview)
|
||||
container.addView(themeLabel)
|
||||
container.addView(lockOverlay)
|
||||
container.addView(levelRequirement)
|
||||
|
||||
// Add container to card
|
||||
card.addView(container)
|
||||
|
||||
// Set up click listener only for unlocked themes
|
||||
if (isUnlocked) {
|
||||
card.setOnClickListener {
|
||||
// Clicking directly selects the theme
|
||||
Log.d("ThemeSelector", "Theme card clicked: $themeId (isUnlocked=$isUnlocked)")
|
||||
focusedThemeId = themeId
|
||||
focusedIndex = themeIdList.indexOf(themeId)
|
||||
confirmSelection() // Directly confirm click selection
|
||||
}
|
||||
}
|
||||
|
||||
return card
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for theme information
|
||||
*/
|
||||
data class ThemeInfo(
|
||||
val displayName: String,
|
||||
val primaryColor: Int,
|
||||
val secondaryColor: Int,
|
||||
val textColor: Int,
|
||||
val unlockLevel: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Get all available themes with their details
|
||||
*/
|
||||
private fun getThemes(): Map<String, ThemeInfo> {
|
||||
return mapOf(
|
||||
PlayerProgressionManager.THEME_CLASSIC to ThemeInfo(
|
||||
displayName = "Classic",
|
||||
primaryColor = Color.parseColor("#000000"),
|
||||
secondaryColor = Color.parseColor("#1F1F1F"),
|
||||
textColor = Color.WHITE,
|
||||
unlockLevel = 1
|
||||
),
|
||||
PlayerProgressionManager.THEME_NEON to ThemeInfo(
|
||||
displayName = "Neon",
|
||||
primaryColor = Color.parseColor("#0D0221"),
|
||||
secondaryColor = Color.parseColor("#650D89"),
|
||||
textColor = Color.parseColor("#FF00FF"),
|
||||
unlockLevel = 5
|
||||
),
|
||||
PlayerProgressionManager.THEME_MONOCHROME to ThemeInfo(
|
||||
displayName = "Monochrome",
|
||||
primaryColor = Color.parseColor("#1A1A1A"),
|
||||
secondaryColor = Color.parseColor("#333333"),
|
||||
textColor = Color.LTGRAY,
|
||||
unlockLevel = 10
|
||||
),
|
||||
PlayerProgressionManager.THEME_RETRO to ThemeInfo(
|
||||
displayName = "Retro",
|
||||
primaryColor = Color.parseColor("#3F2832"),
|
||||
secondaryColor = Color.parseColor("#087E8B"),
|
||||
textColor = Color.parseColor("#FF5A5F"),
|
||||
unlockLevel = 15
|
||||
),
|
||||
PlayerProgressionManager.THEME_MINIMALIST to ThemeInfo(
|
||||
displayName = "Minimalist",
|
||||
primaryColor = Color.parseColor("#FFFFFF"),
|
||||
secondaryColor = Color.parseColor("#F0F0F0"),
|
||||
textColor = Color.BLACK,
|
||||
unlockLevel = 20
|
||||
),
|
||||
PlayerProgressionManager.THEME_GALAXY to ThemeInfo(
|
||||
displayName = "Galaxy",
|
||||
primaryColor = Color.parseColor("#0B0C10"),
|
||||
secondaryColor = Color.parseColor("#1F2833"),
|
||||
textColor = Color.parseColor("#66FCF1"),
|
||||
unlockLevel = 25
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the entire component has focus (from the parent menu).
|
||||
* Controls the outer border visibility.
|
||||
*/
|
||||
fun setHasFocus(hasFocus: Boolean) {
|
||||
hasComponentFocus = hasFocus
|
||||
// Update visual state based on component focus
|
||||
highlightFocusedCard() // Re-apply highlights
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the internal focus to the next available theme.
|
||||
*/
|
||||
fun focusNextItem() {
|
||||
if (!hasComponentFocus || themeIdList.isEmpty()) return // Only navigate if component has focus
|
||||
|
||||
focusedIndex = (focusedIndex + 1) % themeIdList.size
|
||||
focusedThemeId = themeIdList[focusedIndex]
|
||||
highlightFocusedCard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the internal focus to the previous available theme.
|
||||
*/
|
||||
fun focusPreviousItem() {
|
||||
if (!hasComponentFocus || themeIdList.isEmpty()) return // Only navigate if component has focus
|
||||
|
||||
focusedIndex = (focusedIndex - 1 + themeIdList.size) % themeIdList.size
|
||||
focusedThemeId = themeIdList[focusedIndex]
|
||||
highlightFocusedCard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the currently focused theme as the selected theme.
|
||||
* Triggers the onThemeSelected callback.
|
||||
*/
|
||||
fun confirmSelection() {
|
||||
Log.d("ThemeSelector", "confirmSelection called. Focused theme: $focusedThemeId")
|
||||
if (focusedThemeId == null || focusedThemeId == selectedTheme) {
|
||||
// No change needed if nothing is focused,
|
||||
// or the focused item is already selected
|
||||
return
|
||||
}
|
||||
|
||||
// Update the selected theme
|
||||
selectedTheme = focusedThemeId!!
|
||||
|
||||
// Update visual states for all cards
|
||||
highlightFocusedCard() // This will now mark the new theme as selected
|
||||
|
||||
// Trigger the callback
|
||||
onThemeSelected?.invoke(selectedTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visual highlight state of the theme cards based on
|
||||
* selection and internal focus.
|
||||
*/
|
||||
private fun highlightFocusedCard() {
|
||||
if (themeCards.isEmpty()) return
|
||||
|
||||
val focusColor = Color.YELLOW // Color for the focused-but-not-selected item
|
||||
val selectedColor = Color.WHITE // Color for the selected item (might be focused or not)
|
||||
|
||||
themeCards.forEach { (themeId, card) ->
|
||||
val themeInfo = getThemes()[themeId] ?: return@forEach
|
||||
val isUnlocked = themeIdList.contains(themeId) // Check if it's in the navigable list
|
||||
|
||||
if (!isUnlocked) {
|
||||
// Keep locked themes visually distinct
|
||||
card.alpha = 0.5f
|
||||
card.cardElevation = 2f
|
||||
card.background = null // Remove any border/background
|
||||
card.setCardBackgroundColor(themeInfo.primaryColor)
|
||||
return@forEach // Skip further styling for locked themes
|
||||
}
|
||||
|
||||
// Reset unlocked cards first
|
||||
card.alpha = 1.0f
|
||||
card.cardElevation = 4f // Default elevation for unlocked cards
|
||||
card.background = null
|
||||
card.setCardBackgroundColor(themeInfo.primaryColor)
|
||||
card.scaleX = 1.0f
|
||||
card.scaleY = 1.0f
|
||||
|
||||
val isSelected = (themeId == selectedTheme)
|
||||
val isFocused = (hasComponentFocus && themeId == focusedThemeId)
|
||||
|
||||
var borderColor = Color.TRANSPARENT
|
||||
var borderWidth = 0
|
||||
var elevation = 4f
|
||||
var scale = 1.0f
|
||||
|
||||
if (isSelected) {
|
||||
borderColor = selectedColor
|
||||
borderWidth = 6 // Thick border for selected
|
||||
elevation = 12f // Higher elevation for selected
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
// Focused item gets a distinct border (unless it's also selected)
|
||||
if (!isSelected) {
|
||||
borderColor = focusColor
|
||||
borderWidth = 4 // Slightly thinner border for focused
|
||||
}
|
||||
elevation = 12f // Use high elevation for focus too
|
||||
scale = 1.1f // Scale up the focused item
|
||||
}
|
||||
|
||||
// Apply scale
|
||||
card.scaleX = scale
|
||||
card.scaleY = scale
|
||||
|
||||
// Apply border and elevation
|
||||
if (borderWidth > 0) {
|
||||
val gradientDrawable = GradientDrawable().apply {
|
||||
setColor(themeInfo.primaryColor)
|
||||
setStroke(borderWidth, borderColor)
|
||||
cornerRadius = 12f // Keep consistent corner radius
|
||||
}
|
||||
card.background = gradientDrawable
|
||||
} else {
|
||||
card.background = null // Ensure no border if not selected/focused
|
||||
}
|
||||
card.cardElevation = elevation
|
||||
}
|
||||
}
|
||||
}
|
245
app/src/main/java/com/pixelmintdrop/ui/XPProgressBar.kt
Normal file
245
app/src/main/java/com/pixelmintdrop/ui/XPProgressBar.kt
Normal file
|
@ -0,0 +1,245 @@
|
|||
package com.mintris.ui
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.mintris.R
|
||||
|
||||
/**
|
||||
* Custom progress bar for displaying player XP with animation capabilities
|
||||
*/
|
||||
class XPProgressBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
// Paints for drawing
|
||||
private val backgroundPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val progressPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = 40f
|
||||
}
|
||||
|
||||
private val levelBadgePaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val levelBadgeTextPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = 36f
|
||||
isFakeBoldText = true
|
||||
}
|
||||
|
||||
// Progress bar dimensions
|
||||
private val progressRect = RectF()
|
||||
private val backgroundRect = RectF()
|
||||
private val cornerRadius = 25f
|
||||
|
||||
// Progress animation
|
||||
private var progressAnimator: ValueAnimator? = null
|
||||
private var currentProgress = 0f
|
||||
private var targetProgress = 0f
|
||||
|
||||
// XP values
|
||||
private var currentXP = 0L
|
||||
private var xpForNextLevel = 100L
|
||||
private var playerLevel = 1
|
||||
|
||||
// Level up animation
|
||||
private var isLevelingUp = false
|
||||
private var levelUpAnimator: ValueAnimator? = null
|
||||
private var levelBadgeScale = 1f
|
||||
|
||||
// Theme-related properties
|
||||
private var themeColor = Color.WHITE
|
||||
|
||||
/**
|
||||
* Set the player's current level and XP values
|
||||
*/
|
||||
fun setXPValues(level: Int, currentXP: Long, xpForNextLevel: Long) {
|
||||
this.playerLevel = level
|
||||
this.currentXP = currentXP
|
||||
this.xpForNextLevel = xpForNextLevel
|
||||
|
||||
// Update progress value
|
||||
targetProgress = calculateProgressPercentage()
|
||||
|
||||
// If not animating, set current progress immediately
|
||||
if (progressAnimator == null || !progressAnimator!!.isRunning) {
|
||||
currentProgress = targetProgress
|
||||
}
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set theme color for elements
|
||||
*/
|
||||
fun setThemeColor(color: Int) {
|
||||
themeColor = color
|
||||
progressPaint.color = color
|
||||
textPaint.color = color
|
||||
levelBadgePaint.color = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate adding XP to the bar
|
||||
*/
|
||||
fun animateXPGain(xpGained: Long, newLevel: Int, newCurrentXP: Long, newXPForNextLevel: Long) {
|
||||
// Store original values before animation
|
||||
val startXP = currentXP
|
||||
val startLevel = playerLevel
|
||||
|
||||
// Calculate percentage before XP gain
|
||||
val startProgress = calculateProgressPercentage()
|
||||
|
||||
// Update to new values
|
||||
playerLevel = newLevel
|
||||
currentXP = newCurrentXP
|
||||
xpForNextLevel = newXPForNextLevel
|
||||
|
||||
// Calculate new target progress
|
||||
targetProgress = calculateProgressPercentage()
|
||||
|
||||
// Determine if level up occurred
|
||||
isLevelingUp = startLevel < newLevel
|
||||
|
||||
// Animate progress bar
|
||||
progressAnimator?.cancel()
|
||||
progressAnimator = ValueAnimator.ofFloat(startProgress, targetProgress).apply {
|
||||
duration = 1500 // 1.5 seconds animation
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
addUpdateListener { animation ->
|
||||
currentProgress = animation.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// When animation completes, trigger level up animation if needed
|
||||
if (isLevelingUp) {
|
||||
levelUpAnimation()
|
||||
}
|
||||
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a level up animation effect
|
||||
*/
|
||||
private fun levelUpAnimation() {
|
||||
levelUpAnimator?.cancel()
|
||||
levelUpAnimator = ValueAnimator.ofFloat(1f, 1.5f, 1f).apply {
|
||||
duration = 1000 // 1 second pulse animation
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
addUpdateListener { animation ->
|
||||
levelBadgeScale = animation.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the current progress percentage
|
||||
*/
|
||||
private fun calculateProgressPercentage(): Float {
|
||||
return if (xpForNextLevel > 0) {
|
||||
(currentXP.toFloat() / xpForNextLevel.toFloat()) * 100f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
|
||||
// Update progress bar dimensions based on view size
|
||||
val verticalPadding = h * 0.2f
|
||||
// Increase left margin to prevent level badge from being cut off
|
||||
backgroundRect.set(
|
||||
h * 0.6f, // Increased from 0.5f to 0.6f for more space
|
||||
verticalPadding,
|
||||
w - paddingRight.toFloat(),
|
||||
h - verticalPadding
|
||||
)
|
||||
|
||||
// Adjust text size based on height
|
||||
textPaint.textSize = h * 0.35f
|
||||
levelBadgeTextPaint.textSize = h * 0.3f
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
// Draw level badge with adjusted position
|
||||
val badgeRadius = height * 0.3f * levelBadgeScale
|
||||
val badgeCenterX = height * 0.35f // Adjusted from 0.25f to 0.35f to match new position
|
||||
val badgeCenterY = height * 0.5f
|
||||
|
||||
canvas.drawCircle(badgeCenterX, badgeCenterY, badgeRadius, levelBadgePaint)
|
||||
canvas.drawText(
|
||||
playerLevel.toString(),
|
||||
badgeCenterX,
|
||||
badgeCenterY + (levelBadgeTextPaint.textSize / 3),
|
||||
levelBadgeTextPaint
|
||||
)
|
||||
|
||||
// Draw background bar
|
||||
canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint)
|
||||
|
||||
// Draw progress bar
|
||||
progressRect.set(
|
||||
backgroundRect.left,
|
||||
backgroundRect.top,
|
||||
backgroundRect.left + (backgroundRect.width() * currentProgress / 100f),
|
||||
backgroundRect.bottom
|
||||
)
|
||||
|
||||
// Only draw if there is progress to show
|
||||
if (progressRect.width() > 0) {
|
||||
// Draw actual progress bar
|
||||
canvas.drawRoundRect(progressRect, cornerRadius, cornerRadius, progressPaint)
|
||||
}
|
||||
|
||||
// Draw progress text
|
||||
val progressText = "${currentXP}/${xpForNextLevel} XP"
|
||||
canvas.drawText(
|
||||
progressText,
|
||||
backgroundRect.centerX(),
|
||||
backgroundRect.centerY() + (textPaint.textSize / 3),
|
||||
textPaint
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
progressAnimator?.cancel()
|
||||
levelUpAnimator?.cancel()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue