Fix: Correct namespace and applicationId in app build.gradle

This commit is contained in:
cmclark00 2025-04-01 07:51:52 -04:00
parent 5cf8aec02a
commit 5ace9d7fc5
25 changed files with 4 additions and 4 deletions

View 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)
}
}

View 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
}
}

File diff suppressed because it is too large Load diff

View 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())
}
}

View 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
)
}
}

View 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}")
}
}
}

View 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)
}
}
}

File diff suppressed because it is too large Load diff

View 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
}
}

View 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
)
}
}
}
}
}
}
}

View 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)
}
}
}
}
}
}
}

View 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()
}
}

View 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
*/
}

View file

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

View file

@ -0,0 +1,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()
}
}

View file

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

View file

@ -0,0 +1,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
}
}

View 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"
}
}

View 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)
)
)
}
}
}
}

View 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
}
}
}

View 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
)
}
}

View 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()
}
}

View 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
}
}
}

View 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()
}
}