diff --git a/README.md b/README.md index 15b03d7..5bd8440 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Mintris +# pixelmintdrop -A modern Tetris implementation for Android, featuring smooth animations, responsive controls, and a beautiful minimalist design. +A modern block-stacking puzzle game for Android, featuring smooth animations, responsive controls, and a beautiful minimalist design. ## Features ### Core Gameplay -- Classic Tetris mechanics +- Classic block-stacking mechanics - 7-bag randomizer for piece distribution - Ghost piece preview - Hard drop and soft drop @@ -29,7 +29,7 @@ The game features a comprehensive scoring system: - Single line: 40 points - Double: 100 points - Triple: 300 points -- Tetris (4 lines): 1200 points +- Quad (4 lines): 1200 points #### Multipliers @@ -48,15 +48,15 @@ The game features a comprehensive scoring system: - 4 combos: 2.5x - 5+ combos: 3.0x -3. **Back-to-Back Tetris** - - 50% bonus (1.5x) for consecutive Tetris clears - - Resets if a non-Tetris clear is performed +3. **Back-to-Back Quad** + - 50% bonus (1.5x) for consecutive quad clears + - Resets if a non-quad clear is performed 4. **Perfect Clear** - 2x for single line - 3x for double - 4x for triple - - 5x for Tetris + - 5x for quad - Awarded when clearing lines without leaving blocks 5. **All Clear** @@ -102,11 +102,124 @@ The game features a comprehensive scoring system: - Follows Material Design guidelines - Implements high score persistence using SharedPreferences +## Project Improvements and Best Practices + +### Performance Optimizations + +The codebase includes several performance optimizations: + +1. **Release Build Configuration** + - Minification enabled to reduce APK size + - Resource shrinking to remove unused resources + - ProGuard rules to optimize while preserving critical classes + +2. **Memory Management** + - Proper lifecycle handling to prevent memory leaks + - Resource cleanup through `releaseResources()` methods + - Efficient bitmap handling with reuse when possible + +3. **Rendering Efficiency** + - Custom view invalidation limited to areas that need updating + - Hardware acceleration for canvas operations + - Bitmap caching for frequently used graphics + +### Code Organization + +The codebase follows good architecture practices: + +1. **Package Structure** + - `model`: Data classes and game logic + - `game`: Core gameplay implementation + - `ui`: User interface components + - `audio`: Sound and music management + - `accessibility`: Accessibility helpers + +2. **Responsibility Separation** + - `GameLifecycleManager`: Handles lifecycle events + - `GameUIManager`: Manages UI state and updates + - `GameAccessibilityHelper`: Improves accessibility features + - `GamepadController`: Manages gamepad input + +### Google Play Compliance + +The app meets Google Play standards: + +1. **Manifest Configuration** + - Proper permissions declaration + - Screen orientation handling + - Full backup rules for user data + +2. **Accessibility Support** + - Content descriptions for UI elements + - Color contrast considerations + - Screen reader compatibility + +3. **Shortcuts and Deep Links** + - App shortcuts for quick actions + - Proper intent handling + +### Usage Examples + +#### Lifecycle Management + +```kotlin +// In your activity +private lateinit var lifecycleManager: GameLifecycleManager + +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleManager = GameLifecycleManager(this) +} + +override fun onPause() { + super.onPause() + lifecycleManager.onPause(gameView, gameMusic, statsManager, highScoreManager) +} + +override fun onResume() { + super.onResume() + lifecycleManager.onResume(gameView, gameMusic, isMusicEnabled) +} + +override fun onDestroy() { + lifecycleManager.onDestroy(gameView, gameMusic, statsManager, highScoreManager) + super.onDestroy() +} +``` + +#### Accessibility Implementation + +```kotlin +// In your activity +private lateinit var accessibilityHelper: GameAccessibilityHelper + +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + accessibilityHelper = GameAccessibilityHelper(this) + + // Setup accessibility descriptions for controls + accessibilityHelper.setupAccessibilityDescriptions( + leftButton, rightButton, rotateButton, dropButton, + holdButton, pauseButton, gameView, holdPieceView, nextPieceView + ) +} + +// When level changes +private fun onLevelUp(newLevel: Int) { + accessibilityHelper.announceLevelUp(gameView, newLevel) +} + +// When game ends +private fun onGameOver(score: Long) { + accessibilityHelper.announceGameOver(gameView, score) +} +``` + ## Building from Source 1. Clone the repository: ```bash -git clone https://github.com/cmclark00/mintris.git +git clone https://github.com/cmclark00/pixelmintdrop.git ``` 2. Open the project in Android Studio diff --git a/app/build.gradle b/app/build.gradle index da55975..45c83db 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,13 +4,13 @@ plugins { } android { - namespace 'com.mintris' - compileSdk 34 + namespace "com.pixelmintdrop" + compileSdk 35 defaultConfig { - applicationId "com.mintris" + applicationId "com.pixelmintdrop" minSdk 30 - targetSdk 34 + targetSdk 35 versionCode 1 versionName "1.0" @@ -19,13 +19,15 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { viewBinding true + dataBinding true } compileOptions { @@ -38,6 +40,15 @@ android { } } +// Enable strict mode for debug builds +android.applicationVariants.all { variant -> + if (variant.buildType.name == "debug") { + variant.mergedFlavor.manifestPlaceholders = [enableStrictMode: "true"] + } else { + variant.mergedFlavor.manifestPlaceholders = [enableStrictMode: "false"] + } +} + dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.12.0' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e64cabd..461f647 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -6,7 +6,40 @@ # http://developer.android.com/guide/developing/tools/proguard.html # Keep models intact --keep class com.mintris.model.** { *; } +-keep class com.pixelmintdrop.model.** { *; } + +# Keep game classes intact to prevent issues +-keep class com.pixelmintdrop.game.** { *; } + +# Preserve critical classes that might be used through reflection +-keep class com.pixelmintdrop.audio.GameMusic { *; } +-keep class com.pixelmintdrop.ui.** { *; } + +# Keep all public methods in the MainActivity +-keepclassmembers class com.pixelmintdrop.MainActivity { + public *; +} + +# Keep serializable and parcelable classes for proper game state saving +-keepnames class * implements java.io.Serializable +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# Preserve line number information for debugging stack traces +-keepattributes SourceFile,LineNumberTable + +# Keep Gson usage intact +-keep class com.google.gson.** { *; } +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer # Uncomment this to preserve the line number information for # debugging stack traces. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 924b03a..0d57748 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,38 +7,46 @@ + android:theme="@style/Theme.pixelmintdrop"> + android:excludeFromRecents="false" + android:screenOrientation="portrait" + android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"> + + android:exported="false" + android:screenOrientation="portrait" /> \ No newline at end of file diff --git a/app/src/main/java/com/mintris/MainActivity.kt b/app/src/main/java/com/mintris/MainActivity.kt deleted file mode 100644 index 8b4602e..0000000 --- a/app/src/main/java/com/mintris/MainActivity.kt +++ /dev/null @@ -1,758 +0,0 @@ -package com.mintris - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.VibrationEffect -import android.os.Vibrator -import android.view.View -import android.view.HapticFeedbackConstants -import androidx.appcompat.app.AppCompatActivity -import com.mintris.databinding.ActivityMainBinding -import com.mintris.game.GameHaptics -import com.mintris.game.GameView -import com.mintris.game.TitleScreen -import com.mintris.model.GameBoard -import com.mintris.audio.GameMusic -import com.mintris.model.HighScoreManager -import com.mintris.model.PlayerProgressionManager -import com.mintris.model.StatsManager -import com.mintris.ui.ProgressionScreen -import com.mintris.ui.ThemeSelector -import com.mintris.ui.BlockSkinSelector -import java.text.SimpleDateFormat -import java.util.* -import android.graphics.Color -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import android.graphics.Rect -import android.util.Log -import android.view.KeyEvent - -class MainActivity : AppCompatActivity() { - - companion object { - private const val TAG = "MainActivity" - } - - // UI components - private lateinit var binding: ActivityMainBinding - private lateinit var gameView: GameView - private lateinit var gameHaptics: GameHaptics - private lateinit var gameBoard: GameBoard - private lateinit var gameMusic: GameMusic - private lateinit var titleScreen: TitleScreen - private lateinit var highScoreManager: HighScoreManager - private lateinit var statsManager: StatsManager - private lateinit var progressionManager: PlayerProgressionManager - private lateinit var progressionScreen: ProgressionScreen - private lateinit var themeSelector: ThemeSelector - private lateinit var blockSkinSelector: BlockSkinSelector - - // Game state - private var isSoundEnabled = true - private var isMusicEnabled = true - private var selectedLevel = 1 - private val maxLevel = 20 - private var currentScore = 0 - private var currentLevel = 1 - private var gameStartTime: Long = 0 - private var piecesPlaced: Int = 0 - private var currentTheme = PlayerProgressionManager.THEME_CLASSIC - - // Activity result launcher for high score entry - private lateinit var highScoreEntryLauncher: ActivityResultLauncher - - override fun onCreate(savedInstanceState: Bundle?) { - // Register activity result launcher for high score entry - highScoreEntryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - // No matter what the result is, we just show the game over container - progressionScreen.visibility = View.GONE - binding.gameOverContainer.visibility = View.VISIBLE - } - - super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - // Disable Android back gesture to prevent accidental app exits - disableAndroidBackGesture() - - // Initialize game components - gameBoard = GameBoard() - gameHaptics = GameHaptics(this) - gameView = binding.gameView - titleScreen = binding.titleScreen - gameMusic = GameMusic(this) - highScoreManager = HighScoreManager(this) - statsManager = StatsManager(this) - progressionManager = PlayerProgressionManager(this) - themeSelector = binding.themeSelector - blockSkinSelector = binding.blockSkinSelector - - // Set up progression screen - progressionScreen = binding.progressionScreen - progressionScreen.visibility = View.GONE - progressionScreen.onContinue = { - progressionScreen.visibility = View.GONE - binding.gameOverContainer.visibility = View.VISIBLE - } - - // Load and apply theme preference - currentTheme = progressionManager.getSelectedTheme() - applyTheme(currentTheme) - - // Load and apply block skin preference - gameView.setBlockSkin(progressionManager.getSelectedBlockSkin()) - - // Update block skin selector with current selection - blockSkinSelector.updateBlockSkins( - progressionManager.getUnlockedBlocks(), - gameView.getCurrentBlockSkin(), - progressionManager.getPlayerLevel() - ) - - // Set up game view - gameView.setGameBoard(gameBoard) - gameView.setHaptics(gameHaptics) - - // Set up theme selector - themeSelector.onThemeSelected = { themeId: String -> - // Apply the new theme - applyTheme(themeId) - - // Provide haptic feedback as a cue that the theme changed - gameHaptics.vibrateForPieceLock() - - // Refresh the pause menu to immediately show theme changes - if (binding.pauseContainer.visibility == View.VISIBLE) { - showPauseMenu() - } - } - - // Set up block skin selector - blockSkinSelector.onBlockSkinSelected = { skinId: String -> - // Apply the new block skin - gameView.setBlockSkin(skinId) - - // Save the selection - progressionManager.setSelectedBlockSkin(skinId) - - // Provide haptic feedback - gameHaptics.vibrateForPieceLock() - } - - // Set up title screen - titleScreen.onStartGame = { - titleScreen.visibility = View.GONE - gameView.visibility = View.VISIBLE - binding.gameControlsContainer.visibility = View.VISIBLE - startGame() - } - - // Initially hide the game view and show title screen - gameView.visibility = View.GONE - binding.gameControlsContainer.visibility = View.GONE - titleScreen.visibility = View.VISIBLE - - // Set up pause button to show settings menu - binding.pauseButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - gameView.pause() - gameMusic.pause() - showPauseMenu() - binding.pauseStartButton.visibility = View.GONE - binding.resumeButton.visibility = View.VISIBLE - } - - // Set up next piece preview - binding.nextPieceView.setGameView(gameView) - gameBoard.onNextPieceChanged = { - binding.nextPieceView.invalidate() - } - - // Set up hold piece preview - binding.holdPieceView.setGameView(gameView) - gameBoard.onPieceLock = { - binding.holdPieceView.invalidate() - } - gameBoard.onPieceMove = { - binding.holdPieceView.invalidate() - } - - // Set up music toggle - binding.musicToggle.setOnClickListener { - isMusicEnabled = !isMusicEnabled - gameMusic.setEnabled(isMusicEnabled) - updateMusicToggleUI() - } - - // Set up callbacks - gameView.onGameStateChanged = { score, level, lines -> - updateUI(score, level, lines) - } - - gameView.onGameOver = { score -> - showGameOver(score) - } - - gameView.onLineClear = { lineCount -> - Log.d(TAG, "Received line clear callback: $lineCount lines") - // Use enhanced haptic feedback for line clears - if (isSoundEnabled) { - Log.d(TAG, "Sound is enabled, triggering haptic feedback") - try { - gameHaptics.vibrateForLineClear(lineCount) - Log.d(TAG, "Haptic feedback triggered successfully") - } catch (e: Exception) { - Log.e(TAG, "Error triggering haptic feedback", e) - } - } else { - Log.d(TAG, "Sound is disabled, skipping haptic feedback") - } - // Record line clear in stats - statsManager.recordLineClear(lineCount) - } - - // Add callbacks for piece movement and locking - gameView.onPieceMove = { - if (isSoundEnabled) { - gameHaptics.vibrateForPieceMove() - } - } - - gameView.onPieceLock = { - if (isSoundEnabled) { - gameHaptics.vibrateForPieceLock() - } - piecesPlaced++ - } - - // Set up button click listeners with haptic feedback - binding.playAgainButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - hideGameOver() - gameView.reset() - startGame() - } - - binding.resumeButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - hidePauseMenu() - resumeGame() - } - - binding.settingsButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - toggleSound() - } - - // Set up pause menu buttons - binding.pauseStartButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - hidePauseMenu() - gameView.reset() - startGame() - } - - binding.pauseRestartButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - hidePauseMenu() - gameView.reset() - startGame() - } - - binding.highScoresButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - showHighScores() - } - - binding.pauseLevelUpButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - if (selectedLevel < maxLevel) { - selectedLevel++ - updateLevelSelector() - } - } - - binding.pauseLevelDownButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - if (selectedLevel > 1) { - selectedLevel-- - updateLevelSelector() - } - } - - // Set up stats button - binding.statsButton.setOnClickListener { - gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) - val intent = Intent(this, StatsActivity::class.java) - startActivity(intent) - } - - // Initialize level selector - updateLevelSelector() - - // Enable edge-to-edge display - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.setDecorFitsSystemWindows(false) - } - } - - /** - * Update UI with current game state - */ - private fun updateUI(score: Int, level: Int, lines: Int) { - binding.scoreText.text = score.toString() - binding.currentLevelText.text = level.toString() - binding.linesText.text = lines.toString() - binding.comboText.text = gameBoard.getCombo().toString() - - // Update current level for stats - currentLevel = level - - // Force redraw of next piece preview - binding.nextPieceView.invalidate() - } - - /** - * Show game over screen - */ - private fun showGameOver(score: Int) { - val gameTime = System.currentTimeMillis() - gameStartTime - - // Update session stats - statsManager.updateSessionStats( - score = score, - lines = gameBoard.lines, - pieces = piecesPlaced, - time = gameTime, - level = currentLevel - ) - - // Calculate XP earned - val xpGained = progressionManager.calculateGameXP( - score = score, - lines = gameBoard.lines, - level = currentLevel, - gameTime = gameTime, - tetrisCount = statsManager.getSessionTetrises(), - perfectClearCount = 0 // Implement perfect clear tracking if needed - ) - - // Add XP and check for rewards - val newRewards = progressionManager.addXP(xpGained) - - // End session and save stats - statsManager.endSession() - - // Update session stats display - val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - timeFormat.timeZone = TimeZone.getTimeZone("UTC") - - binding.sessionScoreText.text = getString(R.string.session_score, score) - binding.sessionLinesText.text = getString(R.string.session_lines, gameBoard.lines) - binding.sessionPiecesText.text = getString(R.string.session_pieces, piecesPlaced) - binding.sessionTimeText.text = getString(R.string.session_time, timeFormat.format(gameTime)) - binding.sessionLevelText.text = getString(R.string.session_level, currentLevel) - - // Update session line clear stats - binding.sessionSinglesText.text = getString(R.string.singles, statsManager.getSessionSingles()) - binding.sessionDoublesText.text = getString(R.string.doubles, statsManager.getSessionDoubles()) - binding.sessionTriplesText.text = getString(R.string.triples, statsManager.getSessionTriples()) - binding.sessionTetrisesText.text = getString(R.string.tetrises, statsManager.getSessionTetrises()) - - // Flag to track if high score screen will be shown - var showingHighScore = false - - // Show progression screen first with XP animation - showProgressionScreen(xpGained, newRewards) - - // Override the continue button behavior if high score needs to be shown - val originalOnContinue = progressionScreen.onContinue - - progressionScreen.onContinue = { - // If this is a high score, show high score entry screen - if (highScoreManager.isHighScore(score)) { - showingHighScore = true - showHighScoreEntry(score) - } else { - // Just show game over screen normally - progressionScreen.visibility = View.GONE - binding.gameOverContainer.visibility = View.VISIBLE - - // Update theme selector if new themes were unlocked - if (newRewards.any { it.contains("Theme") }) { - updateThemeSelector() - } - } - } - - // Vibrate to indicate game over - vibrate(VibrationEffect.EFFECT_DOUBLE_CLICK) - } - - /** - * Show high score entry screen - */ - private fun showHighScoreEntry(score: Int) { - val intent = Intent(this, HighScoreEntryActivity::class.java).apply { - putExtra("score", score) - putExtra("level", currentLevel) - } - // Use the launcher instead of startActivity - highScoreEntryLauncher.launch(intent) - } - - /** - * Hide game over screen - */ - private fun hideGameOver() { - binding.gameOverContainer.visibility = View.GONE - progressionScreen.visibility = View.GONE - } - - /** - * Show settings menu - */ - private fun showPauseMenu() { - binding.pauseContainer.visibility = View.VISIBLE - binding.pauseStartButton.visibility = View.VISIBLE - binding.resumeButton.visibility = View.GONE - - // Update level badge - binding.pauseLevelBadge.setLevel(progressionManager.getPlayerLevel()) - binding.pauseLevelBadge.setThemeColor(getThemeColor(currentTheme)) - - // Get theme color - val textColor = getThemeColor(currentTheme) - - // Apply theme color to pause container background - 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 - } - binding.pauseContainer.setBackgroundColor(backgroundColor) - - // Apply theme colors to buttons - binding.pauseStartButton.setTextColor(textColor) - binding.pauseRestartButton.setTextColor(textColor) - binding.resumeButton.setTextColor(textColor) - binding.highScoresButton.setTextColor(textColor) - binding.statsButton.setTextColor(textColor) - binding.pauseLevelText.setTextColor(textColor) - binding.pauseLevelUpButton.setTextColor(textColor) - binding.pauseLevelDownButton.setTextColor(textColor) - binding.settingsButton.setTextColor(textColor) - binding.musicToggle.setColorFilter(textColor) - - // Apply theme colors to text elements - binding.settingsTitle.setTextColor(textColor) - binding.selectLevelText.setTextColor(textColor) - binding.musicText.setTextColor(textColor) - - // Update theme selector - updateThemeSelector() - - // Update block skin selector - blockSkinSelector.updateBlockSkins( - progressionManager.getUnlockedBlocks(), - gameView.getCurrentBlockSkin(), - progressionManager.getPlayerLevel() - ) - } - - /** - * Hide settings menu - */ - private fun hidePauseMenu() { - binding.pauseContainer.visibility = View.GONE - } - - /** - * Toggle sound on/off - */ - private fun toggleSound() { - isSoundEnabled = !isSoundEnabled - binding.settingsButton.text = getString( - if (isSoundEnabled) R.string.sound_on else R.string.sound_off - ) - - // Vibrate to provide feedback - vibrate(VibrationEffect.EFFECT_CLICK) - } - - /** - * Update the level selector display - */ - private fun updateLevelSelector() { - binding.pauseLevelText.text = selectedLevel.toString() - gameBoard.updateLevel(selectedLevel) - } - - /** - * Trigger device vibration with predefined effect - */ - private fun vibrate(effectId: Int) { - val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - vibrator.vibrate(VibrationEffect.createPredefined(effectId)) - } - - private fun updateMusicToggleUI() { - binding.musicToggle.setImageResource( - if (isMusicEnabled) R.drawable.ic_volume_up - else R.drawable.ic_volume_off - ) - } - - private fun startGame() { - gameView.start() - gameMusic.setEnabled(isMusicEnabled) - if (isMusicEnabled) { - gameMusic.start() - } - gameStartTime = System.currentTimeMillis() - piecesPlaced = 0 - statsManager.startNewSession() - progressionManager.startNewSession() - gameBoard.updateLevel(selectedLevel) - } - - private fun restartGame() { - gameBoard.reset() - gameView.visibility = View.VISIBLE - gameView.start() - showPauseMenu() - } - - private fun resumeGame() { - gameView.resume() - if (isMusicEnabled) { - gameMusic.resume() - } - // Force a redraw to ensure pieces aren't frozen - gameView.invalidate() - } - - override fun onPause() { - super.onPause() - if (gameView.visibility == View.VISIBLE) { - gameView.pause() - gameMusic.pause() - } - } - - override fun onResume() { - super.onResume() - // If we're on the title screen, don't auto-resume the game - if (titleScreen.visibility == View.GONE && gameView.visibility == View.VISIBLE && binding.gameOverContainer.visibility == View.GONE && binding.pauseContainer.visibility == View.GONE) { - resumeGame() - } - - // Update theme selector with available themes when pause screen appears - updateThemeSelector() - } - - override fun onDestroy() { - super.onDestroy() - gameMusic.release() - } - - /** - * Show title screen (for game restart) - */ - private fun showTitleScreen() { - gameView.reset() - gameView.visibility = View.GONE - binding.gameControlsContainer.visibility = View.GONE - binding.gameOverContainer.visibility = View.GONE - binding.pauseContainer.visibility = View.GONE - titleScreen.visibility = View.VISIBLE - titleScreen.applyTheme(currentTheme) - } - - /** - * Show high scores - */ - private fun showHighScores() { - val intent = Intent(this, HighScoresActivity::class.java) - startActivity(intent) - } - - /** - * Update the theme selector with unlocked themes - */ - private fun updateThemeSelector() { - binding.themeSelector.updateThemes( - unlockedThemes = progressionManager.getUnlockedThemes(), - currentTheme = currentTheme - ) - } - - /** - * Apply a theme to the game - */ - private fun applyTheme(themeId: String) { - currentTheme = themeId - val 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 - } - - // Get background color for the theme - 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 - } - - // Apply background color to root view - binding.root.setBackgroundColor(backgroundColor) - - // Apply theme color to title screen - titleScreen.setThemeColor(themeColor) - titleScreen.setBackgroundColor(backgroundColor) - - // Apply theme color to game over screen - binding.gameOverContainer.setBackgroundColor(backgroundColor) - binding.gameOverText.setTextColor(themeColor) - binding.scoreText.setTextColor(themeColor) - binding.currentLevelText.setTextColor(themeColor) - binding.linesText.setTextColor(themeColor) - binding.comboText.setTextColor(themeColor) - binding.playAgainButton.setTextColor(themeColor) - binding.playAgainButton.setBackgroundResource(android.R.color.transparent) - binding.playAgainButton.setTextSize(24f) - - // Apply theme to progression screen (it will handle its own colors) - progressionScreen.applyTheme(themeId) - - // Apply theme color to game view - gameView.setThemeColor(themeColor) - gameView.setBackgroundColor(backgroundColor) - - // Save theme preference - progressionManager.setSelectedTheme(themeId) - } - - /** - * Get the appropriate color for the current theme - */ - private fun getThemeColor(themeId: String): Int { - return 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 - } - } - - /** - * Disables the Android system back gesture to prevent accidental exits - */ - private fun disableAndroidBackGesture() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Set the entire window to be excluded from the system gesture areas - window.decorView.post { - // Create a list of rectangles representing the edges of the screen to exclude from system gestures - val gestureInsets = window.decorView.rootWindowInsets?.systemGestureInsets - if (gestureInsets != null) { - val leftEdge = Rect(0, 0, 50, window.decorView.height) - val rightEdge = Rect(window.decorView.width - 50, 0, window.decorView.width, window.decorView.height) - val bottomEdge = Rect(0, window.decorView.height - 50, window.decorView.width, window.decorView.height) - - window.decorView.systemGestureExclusionRects = listOf(leftEdge, rightEdge, bottomEdge) - } - } - } - - // Add an on back pressed callback to handle back button/gesture - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // If we're playing the game, handle it as a pause action instead of exiting - if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { - gameView.pause() - gameMusic.pause() - showPauseMenu() - binding.pauseStartButton.visibility = View.GONE - binding.resumeButton.visibility = View.VISIBLE - } else if (binding.pauseContainer.visibility == View.VISIBLE) { - // If pause menu is showing, handle as a resume - resumeGame() - } else if (binding.gameOverContainer.visibility == View.VISIBLE) { - // If game over is showing, go back to title - hideGameOver() - showTitleScreen() - } else if (titleScreen.visibility == View.VISIBLE) { - // If title screen is showing, allow normal back behavior (exit app) - isEnabled = false - onBackPressedDispatcher.onBackPressed() - } - } - }) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // For Android 11 (R) to Android 12 (S), use the WindowInsetsController to disable gestures - window.insetsController?.systemBarsBehavior = - android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - } - - /** - * Completely block the hardware back button during gameplay - */ - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - // If back button is pressed - if (keyCode == KeyEvent.KEYCODE_BACK) { - // Handle back button press as a pause action during gameplay - if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { - gameView.pause() - gameMusic.pause() - showPauseMenu() - binding.pauseStartButton.visibility = View.GONE - binding.resumeButton.visibility = View.VISIBLE - return true // Consume the event - } else if (binding.pauseContainer.visibility == View.VISIBLE) { - // If pause menu is showing, handle as a resume - resumeGame() - return true // Consume the event - } else if (binding.gameOverContainer.visibility == View.VISIBLE) { - // If game over is showing, go back to title - hideGameOver() - showTitleScreen() - return true // Consume the event - } - } - return super.onKeyDown(keyCode, event) - } - - private fun showProgressionScreen(xpGained: Long, newRewards: List) { - // Apply theme before showing the screen - progressionScreen.applyTheme(currentTheme) - - // Show the progression screen - binding.gameOverContainer.visibility = View.GONE - progressionScreen.visibility = View.VISIBLE - - // Display progression data - progressionScreen.showProgress(progressionManager, xpGained, newRewards, currentTheme) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/GameHaptics.kt b/app/src/main/java/com/mintris/game/GameHaptics.kt deleted file mode 100644 index 44e5011..0000000 --- a/app/src/main/java/com/mintris/game/GameHaptics.kt +++ /dev/null @@ -1,94 +0,0 @@ -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 - } - - // 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 - // More lines = longer and stronger vibration - val duration = when(lineCount) { - 1 -> 50L // Single line: short vibration - 2 -> 80L // Double line: slightly longer - 3 -> 120L // Triple line: even longer - 4 -> 200L // Tetris: longest vibration - else -> 50L - } - - val amplitude = when(lineCount) { - 1 -> 80 // Single line: mild vibration (80/255) - 2 -> 120 // Double line: medium vibration (120/255) - 3 -> 180 // Triple line: strong vibration (180/255) - 4 -> 255 // Tetris: maximum vibration (255/255) - else -> 80 - } - - 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 vibrationEffect = VibrationEffect.createOneShot(50L, VibrationEffect.DEFAULT_AMPLITUDE) - vibrator.vibrate(vibrationEffect) - } - } - - fun vibrateForPieceMove() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val amplitude = (VibrationEffect.DEFAULT_AMPLITUDE * 0.3).toInt().coerceAtLeast(1) - val vibrationEffect = VibrationEffect.createOneShot(20L, amplitude) - vibrator.vibrate(vibrationEffect) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/NextPieceView.kt b/app/src/main/java/com/mintris/game/NextPieceView.kt deleted file mode 100644 index c82fe2c..0000000 --- a/app/src/main/java/com/mintris/game/NextPieceView.kt +++ /dev/null @@ -1,99 +0,0 @@ -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) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/model/GameBoard.kt b/app/src/main/java/com/mintris/model/GameBoard.kt deleted file mode 100644 index ce26763..0000000 --- a/app/src/main/java/com/mintris/model/GameBoard.kt +++ /dev/null @@ -1,688 +0,0 @@ -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() - - // 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 - - // 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() - 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) -> Unit)? = null - - // Store the last cleared lines - private val lastClearedLines = mutableListOf() - - // 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 - spawnNextPiece() - spawnPiece() - } 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 - currentPiece?.apply { - x = (width - getWidth()) / 2 - y = 0 - - 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 - } - } - } - } - - /** - * 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 - } - } - } - } - - 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() - - // 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() - - 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) { - level = newLevel.coerceIn(1, 20) - startingLevel = level // Store the starting level - // Update game speed based on level (NES formula) - dropInterval = (1000 * Math.pow(0.8, (level - 1).toDouble())).toLong() - } - - /** - * 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 - lines = 0 - isGameOver = false - dropInterval = (1000 * Math.pow(0.8, (level - 1).toDouble())).toLong() // Set speed based on current level - - // 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 { - return lastClearedLines.toList() - } - - /** - * Update the game state (called by game loop) - */ - fun update() { - if (!isGameOver) { - moveDown() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/model/StatsManager.kt b/app/src/main/java/com/mintris/model/StatsManager.kt deleted file mode 100644 index cc5272f..0000000 --- a/app/src/main/java/com/mintris/model/StatsManager.kt +++ /dev/null @@ -1,196 +0,0 @@ -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" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/ui/BlockSkinSelector.kt b/app/src/main/java/com/mintris/ui/BlockSkinSelector.kt deleted file mode 100644 index d042059..0000000 --- a/app/src/main/java/com/mintris/ui/BlockSkinSelector.kt +++ /dev/null @@ -1,308 +0,0 @@ -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 - -/** - * 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 - private var selectedSkin: String = "block_skin_1" - - // Block skin cards - private val skinCards = mutableMapOf() - - // 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, currentSkin: String, playerLevel: Int = 1) { - // Store player level - this.playerLevel = playerLevel - - // Clear existing skin cards - skinsGrid.removeAllViews() - skinCards.clear() - - // Update selected skin - selectedSkin = currentSkin - - // Get all possible skins and their details - val allSkins = getBlockSkins() - - // Add skin cards to the grid - allSkins.forEach { (skinId, skinInfo) -> - val isUnlocked = unlockedSkins.contains(skinId) || playerLevel >= skinInfo.unlockLevel - val isSelected = skinId == selectedSkin - - val skinCard = createBlockSkinCard(skinId, skinInfo, isUnlocked, isSelected) - skinCards[skinId] = skinCard - skinsGrid.addView(skinCard) - } - } - - /** - * Set the selected skin with a visual effect - */ - fun setSelectedSkin(skinId: String) { - if (skinId == selectedSkin) return - - // Update previously selected card - skinCards[selectedSkin]?.let { prevCard -> - prevCard.cardElevation = 2f - // Reset any special styling - prevCard.background = null - prevCard.setCardBackgroundColor(getBlockSkins()[selectedSkin]?.backgroundColor ?: Color.BLACK) - } - - // Update visual state of newly selected card - skinCards[skinId]?.let { card -> - card.cardElevation = 12f - - // Flash animation for selection feedback - val flashColor = Color.WHITE - val originalColor = getBlockSkins()[skinId]?.backgroundColor ?: Color.BLACK - - // Create animator for flash effect - val flashAnimator = android.animation.ValueAnimator.ofArgb(flashColor, originalColor) - flashAnimator.duration = 300 // 300ms - flashAnimator.addUpdateListener { animator -> - val color = animator.animatedValue as Int - card.setCardBackgroundColor(color) - } - flashAnimator.start() - - // Add special border to selected card - val gradientDrawable = android.graphics.drawable.GradientDrawable().apply { - setColor(originalColor) - setStroke(6, Color.WHITE) // Thicker border - cornerRadius = 12f - } - card.background = gradientDrawable - } - - // Update selected skin - selectedSkin = skinId - } - - /** - * 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) - - // Add more noticeable visual indicator for selected skin - if (isSelected) { - setContentPadding(4, 4, 4, 4) - // Create a gradient drawable for the border - val gradientDrawable = android.graphics.drawable.GradientDrawable().apply { - setColor(skinInfo.backgroundColor) - setStroke(6, Color.WHITE) // Thicker border - cornerRadius = 12f - } - background = gradientDrawable - // Add glow effect via elevation - cardElevation = 12f - } - - // 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 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 { - // Only trigger callback if this isn't already the selected skin - if (skinId != selectedSkin) { - // Update UI for selection - setSelectedSkin(skinId) - - // Notify listener - onBlockSkinSelected?.invoke(skinId) - } - } - } - - 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 { - 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 - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/HighScoreEntryActivity.kt b/app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt similarity index 62% rename from app/src/main/java/com/mintris/HighScoreEntryActivity.kt rename to app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt index 5290cf6..09e2064 100644 --- a/app/src/main/java/com/mintris/HighScoreEntryActivity.kt +++ b/app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt @@ -1,17 +1,15 @@ -package com.mintris +package com.pixelmintdrop 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 com.pixelmintdrop.databinding.HighScoreEntryBinding +import com.pixelmintdrop.model.HighScore +import com.pixelmintdrop.model.HighScoreManager +import com.pixelmintdrop.model.PlayerProgressionManager import android.graphics.Color +import android.view.KeyEvent +import android.view.InputDevice class HighScoreEntryActivity : AppCompatActivity() { private lateinit var binding: HighScoreEntryBinding @@ -39,20 +37,55 @@ class HighScoreEntryActivity : AppCompatActivity() { binding.scoreText.text = "Score: $score" binding.saveButton.setOnClickListener { - // 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) + 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 diff --git a/app/src/main/java/com/mintris/HighScoresActivity.kt b/app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt similarity index 76% rename from app/src/main/java/com/mintris/HighScoresActivity.kt rename to app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt index d91dfad..ea21392 100644 --- a/app/src/main/java/com/mintris/HighScoresActivity.kt +++ b/app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt @@ -1,17 +1,16 @@ -package com.mintris +package com.pixelmintdrop 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 com.pixelmintdrop.databinding.HighScoresBinding +import com.pixelmintdrop.model.HighScoreAdapter +import com.pixelmintdrop.model.HighScoreManager +import com.pixelmintdrop.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 @@ -113,4 +112,29 @@ class HighScoresActivity : AppCompatActivity() { 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 + } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/MainActivity.kt b/app/src/main/java/com/pixelmintdrop/MainActivity.kt new file mode 100644 index 0000000..9384137 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/MainActivity.kt @@ -0,0 +1,2041 @@ +package com.pixelmintdrop + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator +import android.view.View +import android.view.HapticFeedbackConstants +import androidx.appcompat.app.AppCompatActivity +import com.pixelmintdrop.databinding.ActivityMainBinding +import com.pixelmintdrop.game.GameHaptics +import com.pixelmintdrop.game.GameView +import com.pixelmintdrop.game.TitleScreen +import com.pixelmintdrop.model.GameBoard +import com.pixelmintdrop.audio.GameMusic +import com.pixelmintdrop.model.HighScoreManager +import com.pixelmintdrop.model.PlayerProgressionManager +import com.pixelmintdrop.model.StatsManager +import com.pixelmintdrop.ui.ProgressionScreen +import com.pixelmintdrop.ui.ThemeSelector +import com.pixelmintdrop.ui.BlockSkinSelector +import java.text.SimpleDateFormat +import java.util.* +import android.graphics.Color +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import android.graphics.Rect +import android.util.Log +import android.view.KeyEvent +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import com.pixelmintdrop.game.GamepadController +import android.view.InputDevice +import android.widget.Toast +import android.content.BroadcastReceiver +import android.content.IntentFilter +import android.graphics.drawable.ColorDrawable +import androidx.core.content.ContextCompat +import android.widget.Button +import android.widget.ScrollView +import android.widget.ImageButton +import android.graphics.drawable.GradientDrawable +import android.widget.TextView +import android.widget.Switch + +class MainActivity : AppCompatActivity(), + GamepadController.GamepadConnectionListener, + GamepadController.GamepadMenuListener, + GamepadController.GamepadNavigationListener { + + companion object { + private const val TAG = "MainActivity" + } + + // UI components + private lateinit var binding: ActivityMainBinding + private lateinit var gameView: GameView + private lateinit var gameHaptics: GameHaptics + private lateinit var gameBoard: GameBoard + private lateinit var gameMusic: GameMusic + private lateinit var titleScreen: TitleScreen + private lateinit var highScoreManager: HighScoreManager + private lateinit var statsManager: StatsManager + private lateinit var progressionManager: PlayerProgressionManager + private lateinit var progressionScreen: ProgressionScreen + private lateinit var themeSelector: ThemeSelector + private lateinit var blockSkinSelector: BlockSkinSelector + private var pauseMenuScrollView: ScrollView? = null + + // Game state + private var isSoundEnabled = true + private var isMusicEnabled = true + private var currentScore = 0L + private var currentLevel = 1 + private var piecesPlaced = 0 + private var gameStartTime = 0L + private var selectedLevel = 1 + private val maxLevel = 20 + private var lastLines = 0 // Track the previous lines count + private var lastLinesGroup = 0 // Track which 10-line group we're in (0-9, 10-19, etc.) + private var lastRandomLevel = 0 // Track the level at which we last did a random change + private var isRandomModeEnabled = false + private var currentTheme = PlayerProgressionManager.THEME_CLASSIC + private val handler = Handler(Looper.getMainLooper()) + + // Activity result launcher for high score entry + private lateinit var highScoreEntryLauncher: ActivityResultLauncher + + // Gamepad controller + private lateinit var gamepadController: GamepadController + + // Input device change receiver + private val inputDeviceReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_CONFIGURATION_CHANGED -> { + // A gamepad might have connected or disconnected + checkGamepadConnections() + } + } + } + } + + // Track connected gamepads to detect changes + private val connectedGamepads = mutableSetOf() + + // Track currently selected menu item in pause menu for gamepad navigation + private var currentMenuSelection = 0 + private val pauseMenuItems = mutableListOf() + private val customizationMenuItems = mutableListOf() + + // Add these new properties at the class level + private var currentCustomizationMenuSelection = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + // Register activity result launcher for high score entry + highScoreEntryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + // No matter what the result is, we just show the game over container + progressionScreen.visibility = View.GONE + binding.gameOverContainer.visibility = View.VISIBLE + + // Keep all game UI elements hidden + binding.gameControlsContainer.visibility = View.GONE + binding.holdPieceView.visibility = View.GONE + binding.nextPieceView.visibility = View.GONE + binding.pauseButton.visibility = View.GONE + binding.leftControlsPanel?.visibility = View.GONE + binding.rightControlsPanel?.visibility = View.GONE + } + + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Disable Android back gesture to prevent accidental app exits + disableAndroidBackGesture() + + // Initialize game components + gameBoard = GameBoard() + gameHaptics = GameHaptics(this) + gameView = binding.gameView + titleScreen = binding.titleScreen + gameMusic = GameMusic(this) + highScoreManager = HighScoreManager(this) + statsManager = StatsManager(this) + progressionManager = PlayerProgressionManager(this) + themeSelector = binding.customizationThemeSelector!! + blockSkinSelector = binding.customizationBlockSkinSelector!! + pauseMenuScrollView = binding.pauseMenuScrollView + + // Load random mode setting + isRandomModeEnabled = getSharedPreferences("com.com.pixelmintgames.pixelmintdrop.preferences", Context.MODE_PRIVATE) + .getBoolean("random_mode_enabled", false) + + // Initialize gamepad controller + gamepadController = GamepadController(gameView) + gamepadController.setGamepadConnectionListener(this) + gamepadController.setGamepadMenuListener(this) + gamepadController.setGamepadNavigationListener(this) + + // Set up touch event forwarding + binding.touchInterceptor?.setOnTouchListener { _, event -> + if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { + gameView.onTouchEvent(event) + true + } else { + false + } + } + + // Set up progression screen + progressionScreen = binding.progressionScreen + progressionScreen.visibility = View.GONE + progressionScreen.onContinue = { + progressionScreen.visibility = View.GONE + binding.gameOverContainer.visibility = View.VISIBLE + + // Keep all game UI elements hidden + binding.gameControlsContainer.visibility = View.GONE + binding.holdPieceView.visibility = View.GONE + binding.nextPieceView.visibility = View.GONE + binding.pauseButton.visibility = View.GONE + binding.leftControlsPanel?.visibility = View.GONE + binding.rightControlsPanel?.visibility = View.GONE + + // Get all themes with "Theme" in their name + val themeRewards = progressionManager.getUnlockedThemes().filter { + it.contains("Theme", ignoreCase = true) + } + + // Update theme selector if new themes were unlocked + if (themeRewards.isNotEmpty()) { + updateThemeSelector() + } + } + + // Load and apply theme preference + currentTheme = progressionManager.getSelectedTheme() + applyTheme(currentTheme) + + // Load and apply block skin preference + gameView.setBlockSkin(progressionManager.getSelectedBlockSkin()) + + // Update block skin selector with current selection + blockSkinSelector.updateBlockSkins( + progressionManager.getUnlockedBlocks(), + gameView.getCurrentBlockSkin(), + progressionManager.getPlayerLevel() + ) + + // Set up game view + gameView.setGameBoard(gameBoard) + gameView.setHaptics(gameHaptics) + + // Set up theme selector + themeSelector.onThemeSelected = { themeId: String -> + // Apply the new theme + applyTheme(themeId) + + // Provide haptic feedback as a cue that the theme changed + gameHaptics.vibrateForPieceLock() + + // Refresh the pause menu to immediately show theme changes + if (binding.pauseContainer.visibility == View.VISIBLE) { + showPauseMenu() + } + } + + // Set up title screen + titleScreen.onStartGame = { + titleScreen.visibility = View.GONE + gameView.visibility = View.VISIBLE + binding.gameControlsContainer.visibility = View.VISIBLE + startGame() + } + + // Initially hide the game view and show title screen + gameView.visibility = View.GONE + binding.gameControlsContainer.visibility = View.GONE + titleScreen.visibility = View.VISIBLE + + // Set up pause button to show settings menu + binding.pauseButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + gameView.pause() + gameMusic.pause() + showPauseMenu() + binding.pauseStartButton.visibility = View.GONE + binding.resumeButton.visibility = View.VISIBLE + } + + // Set up next piece preview + binding.nextPieceView.setGameView(gameView) + gameBoard.onNextPieceChanged = { + binding.nextPieceView.invalidate() + } + + // Set up hold piece preview + binding.holdPieceView.setGameView(gameView) + gameBoard.onPieceLock = { + binding.holdPieceView.invalidate() + } + gameBoard.onPieceMove = { + binding.holdPieceView.invalidate() + } + gameBoard.onPiecePlaced = { + piecesPlaced++ + } + + // Set up music toggle + binding.musicToggle.setOnClickListener { + isMusicEnabled = !isMusicEnabled + gameMusic.setEnabled(isMusicEnabled) + updateMusicToggleUI() + } + + // Set up callbacks + gameView.onGameStateChanged = { score, level, lines -> + updateGameStateUI(score, level, lines) + } + + gameView.onGameOver = { finalScore -> + // Pause music on game over + gameMusic.pause() + + // Update high scores + val timePlayedMs = System.currentTimeMillis() - gameStartTime + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val currentDate = dateFormat.format(Date()) + + // Track if this is a high score + val isHighScore = highScoreManager.isHighScore(finalScore) + + // Show game over screen + showGameOver(finalScore) + + // Save player stats to track game history + statsManager.updateSessionStats(finalScore, gameBoard.lines, piecesPlaced, timePlayedMs, currentLevel) + + // Handle progression - XP earned, potential level up + val xpGained = progressionManager.calculateGameXP( + score = finalScore, + lines = gameBoard.lines, + level = currentLevel, + timePlayedMs = timePlayedMs, + quadCount = statsManager.getSessionQuads(), + perfectClearCount = statsManager.getSessionPerfectClears() + ) + val newRewards = progressionManager.addXP(xpGained) + + // Show progression screen if player earned XP + if (xpGained > 0) { + // Delay showing progression screen for a moment + Handler(Looper.getMainLooper()).postDelayed({ + showProgressionScreen(xpGained, newRewards) + }, 2000) + } + } + + gameView.onLineClear = { lineCount -> + Log.d(TAG, "Received line clear callback: $lineCount lines") + // Use enhanced haptic feedback for line clears + if (isSoundEnabled) { + Log.d(TAG, "Sound is enabled, triggering haptic feedback") + try { + gameHaptics.vibrateForLineClear(lineCount) + Log.d(TAG, "Haptic feedback triggered successfully") + } catch (e: Exception) { + Log.e(TAG, "Error triggering haptic feedback", e) + } + } else { + Log.d(TAG, "Sound is disabled, skipping haptic feedback") + } + // Record line clear in stats + statsManager.recordLineClear(lineCount) + } + + // Add callbacks for piece movement and locking + gameView.onPieceMove = { + if (isSoundEnabled) { + gameHaptics.vibrateForPieceMove() + } + } + + // Set up button click listeners with haptic feedback + binding.playAgainButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + hideGameOver() + gameView.reset() + startGame() + } + + binding.resumeButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + hidePauseMenu() + resumeGame() + } + + binding.settingsButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + toggleSound() + } + + // Set up pause menu buttons + binding.pauseStartButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + hidePauseMenu() + gameView.reset() + startGame() + } + + binding.pauseRestartButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + hidePauseMenu() + gameView.reset() + startGame() + } + + binding.highScoresButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + showHighScores() + } + + binding.pauseLevelUpButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + if (selectedLevel < maxLevel) { + selectedLevel++ + updateLevelSelector() + } + } + + binding.pauseLevelDownButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + if (selectedLevel > 1) { + selectedLevel-- + updateLevelSelector() + } + } + + // Set up stats button + binding.statsButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + val intent = Intent(this, StatsActivity::class.java) + startActivity(intent) + } + + // Set up customization button + binding.customizationButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + showCustomizationMenu() + } + + // Set up customization back button + binding.customizationBackButton.setOnClickListener { + gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) + hideCustomizationMenu() + } + + // Initialize level selector + updateLevelSelector() + + // Enable edge-to-edge display + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(false) + } + + // Initialize pause menu items for gamepad navigation + initPauseMenuNavigation() + } + + /** + * Update UI with current game state + */ + private fun updateUI(score: Int, level: Int, lines: Int) { + binding.scoreText.text = score.toString() + binding.currentLevelText.text = level.toString() + binding.linesText.text = lines.toString() + binding.comboText.text = gameBoard.getCombo().toString() + + // Update current level for stats + currentLevel = level + + // Force redraw of next piece preview + binding.nextPieceView.invalidate() + } + + /** + * Show game over screen + */ + private fun showGameOver(score: Int) { + Log.d("MainActivity", "Showing game over screen with score: $score") + val gameTime = System.currentTimeMillis() - gameStartTime + + // Hide all game UI elements + binding.gameControlsContainer.visibility = View.GONE + binding.holdPieceView.visibility = View.GONE + binding.nextPieceView.visibility = View.GONE + binding.pauseButton.visibility = View.GONE + + // Hide landscape panels if they exist + binding.leftControlsPanel?.visibility = View.GONE + binding.rightControlsPanel?.visibility = View.GONE + + // Update session stats + statsManager.updateSessionStats( + score = score, + lines = gameBoard.lines, + pieces = piecesPlaced, + time = gameTime, + level = currentLevel + ) + + // Calculate XP earned + val xpGained = progressionManager.calculateGameXP( + score = score, + lines = gameBoard.lines, + level = currentLevel, + timePlayedMs = gameTime, + quadCount = statsManager.getSessionQuads(), + perfectClearCount = statsManager.getSessionPerfectClears() + ) + + // Add XP and check for rewards + val newRewards = progressionManager.addXP(xpGained) + + // End session and save stats + statsManager.endSession() + + // Update session stats display + val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormat.timeZone = TimeZone.getTimeZone("UTC") + + binding.sessionScoreText.text = getString(R.string.session_score, score) + binding.sessionLinesText.text = getString(R.string.session_lines, gameBoard.lines) + binding.sessionPiecesText.text = getString(R.string.session_pieces, piecesPlaced) + binding.sessionTimeText.text = getString(R.string.session_time, timeFormat.format(gameTime)) + binding.sessionLevelText.text = getString(R.string.session_level, currentLevel) + + // Update session line clear stats + binding.sessionSinglesText.text = getString(R.string.singles, statsManager.getSessionSingles()) + binding.sessionDoublesText.text = getString(R.string.doubles, statsManager.getSessionDoubles()) + binding.sessionTriplesText.text = getString(R.string.triples, statsManager.getSessionTriples()) + binding.sessionQuadsText.text = getString(R.string.quads, statsManager.getSessionQuads()) + + // Flag to track if high score screen will be shown + var showingHighScore = false + + // Play game over sound and trigger animation + if (isSoundEnabled) { + gameMusic.playGameOver() + } + + // First trigger the animation in the game view + Log.d("MainActivity", "Triggering game over animation") + gameView.startGameOverAnimation() + + // Wait a moment before showing progression screen to let animation be visible + Handler(Looper.getMainLooper()).postDelayed({ + // Show progression screen first with XP animation + showProgressionScreen(xpGained, newRewards) + + // Override the continue button behavior if high score needs to be shown + val originalOnContinue = progressionScreen.onContinue + + progressionScreen.onContinue = { + // If this is a high score, show high score entry screen + if (highScoreManager.isHighScore(score)) { + showingHighScore = true + showHighScoreEntry(score) + } else { + // Just show game over screen normally + progressionScreen.visibility = View.GONE + binding.gameOverContainer.visibility = View.VISIBLE + + // Keep all game UI elements hidden + binding.gameControlsContainer.visibility = View.GONE + binding.holdPieceView.visibility = View.GONE + binding.nextPieceView.visibility = View.GONE + binding.pauseButton.visibility = View.GONE + binding.leftControlsPanel?.visibility = View.GONE + binding.rightControlsPanel?.visibility = View.GONE + + // Get all themes with "Theme" in their name + val themeRewards = progressionManager.getUnlockedThemes().filter { + it.contains("Theme", ignoreCase = true) + } + + // Update theme selector if new themes were unlocked + if (themeRewards.isNotEmpty()) { + updateThemeSelector() + } + } + } + }, 2000) // Increased from 1000ms (1 second) to 2000ms (2 seconds) + + // Vibrate to indicate game over + vibrate(VibrationEffect.EFFECT_DOUBLE_CLICK) + } + + /** + * Show high score entry screen + */ + private fun showHighScoreEntry(score: Int) { + val intent = Intent(this, HighScoreEntryActivity::class.java).apply { + putExtra("score", score) + putExtra("level", currentLevel) + } + // Use the launcher instead of startActivity + highScoreEntryLauncher.launch(intent) + } + + /** + * Hide game over screen + */ + private fun hideGameOver() { + binding.gameOverContainer.visibility = View.GONE + progressionScreen.visibility = View.GONE + } + + /** + * Show customization menu + */ + private fun showCustomizationMenu() { + binding.pauseContainer.visibility = View.GONE + binding.customizationContainer.visibility = View.VISIBLE + + // Get theme colors + val themeColor = getThemeColor(currentTheme) + val backgroundColor = getThemeBackgroundColor(currentTheme) + + // Apply background color to customization container + binding.customizationContainer.setBackgroundColor(backgroundColor) + + // Update level badge with player's level and theme color + binding.customizationLevelBadge.setLevel(progressionManager.getPlayerLevel()) + binding.customizationLevelBadge.setThemeColor(themeColor) + + // Apply theme color to all text views in the container + applyThemeColorToTextViews(binding.customizationContainer, themeColor) + + // Update theme selector with available themes + binding.customizationThemeSelector?.updateThemes( + unlockedThemes = progressionManager.getUnlockedThemes(), + currentTheme = currentTheme + ) + + // Update block skin selector with available skins + binding.customizationBlockSkinSelector?.updateBlockSkins( + progressionManager.getUnlockedBlocks(), + gameView.getCurrentBlockSkin(), + progressionManager.getPlayerLevel() + ) + + // Set up block skin selection callback + binding.customizationBlockSkinSelector?.onBlockSkinSelected = { blockSkin -> + gameView.setBlockSkin(blockSkin) + progressionManager.setSelectedBlockSkin(blockSkin) + gameHaptics.vibrateForPieceLock() + } + + // Initialize customization menu items for gamepad navigation + initCustomizationMenuNavigation() + + // Set up random mode switch + binding.randomModeSwitch?.apply { + isChecked = isRandomModeEnabled + isEnabled = progressionManager.getPlayerLevel() >= 5 + setOnCheckedChangeListener { _, isChecked -> + isRandomModeEnabled = isChecked + getSharedPreferences("com.com.pixelmintgames.pixelmintdrop.preferences", Context.MODE_PRIVATE) + .edit() + .putBoolean("random_mode_enabled", isChecked) + .apply() + } + } + } + + private fun initCustomizationMenuNavigation() { + customizationMenuItems.clear() + + // Add items in order + customizationMenuItems.addAll(listOf( + binding.customizationThemeSelector, + binding.customizationBlockSkinSelector, + binding.randomModeSwitch, + binding.customizationBackButton + ).filterNotNull().filter { it.visibility == View.VISIBLE }) + + // Set up focus change listener for scrolling + binding.customizationMenuScrollView.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + // When scroll view gets focus, scroll to focused item + val focusedView = currentFocus + if (focusedView != null) { + val scrollView = binding.customizationMenuScrollView + val itemTop = focusedView.top + val scrollViewHeight = scrollView.height + + // Calculate scroll position to center the focused item + val scrollY = itemTop - (scrollViewHeight / 2) + (focusedView.height / 2) + scrollView.smoothScrollTo(0, scrollY) + } + } + } + + // Set up focus handling between items + customizationMenuItems.forEachIndexed { index, item -> + item.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + currentCustomizationMenuSelection = index + highlightCustomizationMenuItem(index) + } + } + } + } + + /** + * Hide customization menu + */ + private fun hideCustomizationMenu() { + binding.customizationContainer.visibility = View.GONE + binding.pauseContainer.visibility = View.VISIBLE + } + + /** + * Show settings menu + */ + private fun showPauseMenu() { + binding.pauseContainer.visibility = View.VISIBLE + binding.customizationContainer.visibility = View.GONE + + // Set button visibility based on game state + if (gameView.isPaused) { + binding.resumeButton.visibility = View.VISIBLE + binding.pauseStartButton.visibility = View.GONE + } else { + // This case might happen if pause is triggered before game start (e.g., from title) + binding.resumeButton.visibility = View.GONE + binding.pauseStartButton.visibility = View.VISIBLE + } + + // Check if we're in landscape mode (used for finding specific views, not for order) + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + // Update level badge + binding.pauseLevelBadge?.setLevel(progressionManager.getPlayerLevel()) + binding.pauseLevelBadge?.setThemeColor(getThemeColor(currentTheme)) + + // Get theme colors + val themeColor = getThemeColor(currentTheme) + val backgroundColor = getThemeBackgroundColor(currentTheme) + + // Apply background color to pause container + binding.pauseContainer.setBackgroundColor(backgroundColor) + + // Apply theme colors to buttons and toggles + binding.pauseStartButton.setTextColor(themeColor) + binding.pauseRestartButton.setTextColor(themeColor) + binding.resumeButton.setTextColor(themeColor) + binding.highScoresButton.setTextColor(themeColor) + binding.statsButton.setTextColor(themeColor) + binding.pauseLevelUpButton?.setTextColor(themeColor) // Safe call + binding.pauseLevelDownButton?.setTextColor(themeColor) // Safe call + binding.settingsButton?.setTextColor(themeColor) // Safe call for sound toggle button text + binding.musicToggle?.setColorFilter(themeColor) // Safe call + binding.customizationButton?.setTextColor(themeColor) // Safe call + + // Apply theme colors to text elements (using safe calls) + binding.settingsTitle?.setTextColor(themeColor) + binding.selectLevelText?.setTextColor(themeColor) + binding.musicText?.setTextColor(themeColor) + binding.pauseLevelText?.setTextColor(themeColor) + + // Apply to portrait container as well if needed (assuming root or specific container) + applyThemeColorToTextViews(binding.pauseContainer, themeColor) // Apply to main container + + // Reset scroll position + binding.pauseMenuScrollView?.scrollTo(0, 0) + findLandscapeScrollView()?.scrollTo(0, 0) + + // Initialize pause menu navigation (builds the list of navigable items) + initPauseMenuNavigation() + + // Reset selection index (will be set and highlighted in showPauseMenu) + currentMenuSelection = 0 + + // Highlight the initially selected menu item + if (pauseMenuItems.isNotEmpty()) { + highlightMenuItem(currentMenuSelection) + } + } + + /** Helper to apply theme color to TextViews within a container, avoiding selectors */ + private fun applyThemeColorToTextViews(container: View?, themeColor: Int) { + if (container == null) return + if (container is android.view.ViewGroup) { + for (i in 0 until container.childCount) { + val child = container.getChildAt(i) + if (child is TextView) { + // Check if parent is a selector + var parent = child.parent + var isInSelector = false + while (parent != null) { + if (parent is ThemeSelector || parent is BlockSkinSelector) { + isInSelector = true + break + } + if (parent === container) break // Stop at the container boundary + parent = parent.parent + } + if (!isInSelector) { + child.setTextColor(themeColor) + } + } else if (child is android.view.ViewGroup) { + // Recurse only if not a selector + if (child !is ThemeSelector && child !is BlockSkinSelector) { + applyThemeColorToTextViews(child, themeColor) + } + } + } + } + } + + /** Helper to get background color based on theme */ + private fun getThemeBackgroundColor(themeId: String): Int { + return 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 + } + } + + /** + * Hide settings menu + */ + private fun hidePauseMenu() { + binding.pauseContainer.visibility = View.GONE + } + + /** + * Toggle sound on/off + */ + private fun toggleSound() { + isSoundEnabled = !isSoundEnabled + binding.settingsButton.text = getString( + if (isSoundEnabled) R.string.sound_on else R.string.sound_off + ) + + // Vibrate to provide feedback + vibrate(VibrationEffect.EFFECT_CLICK) + } + + /** + * Update the level selector display + */ + private fun updateLevelSelector() { + binding.pauseLevelText.text = selectedLevel.toString() + gameBoard.updateLevel(selectedLevel) + } + + /** + * Trigger device vibration with predefined effect + */ + private fun vibrate(effectId: Int) { + val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + vibrator.vibrate(VibrationEffect.createPredefined(effectId)) + } + + private fun updateMusicToggleUI() { + binding.musicToggle.setImageResource( + if (isMusicEnabled) R.drawable.ic_volume_up + else R.drawable.ic_volume_off + ) + } + + /** + * Start a new game + */ + private fun startGame() { + // Reset pieces placed counter + piecesPlaced = 0 + + // Set initial game state + currentScore = 0 + currentLevel = selectedLevel + lastLines = 0 // Reset lastLines to 0 + lastLinesGroup = 0 // Reset lastLinesGroup to 0 + lastRandomLevel = 0 // Reset lastRandomLevel to 0 + gameStartTime = System.currentTimeMillis() + + // Update UI to show initial values + binding.scoreText.text = "$currentScore" + binding.currentLevelText.text = "$currentLevel" + binding.linesText.text = "0" + binding.comboText.text = "0" + + // Reset game view and game board + gameBoard.reset() + gameView.reset() + + // Ensure block skin is properly set (helps with random mode) + val selectedSkin = progressionManager.getSelectedBlockSkin() + Log.d("RandomMode", "Game start: Setting block skin to $selectedSkin") + gameView.setBlockSkin(selectedSkin) + + // Update selectors to refresh UI state + blockSkinSelector.updateBlockSkins( + progressionManager.getUnlockedBlocks(), + selectedSkin, + progressionManager.getPlayerLevel() + ) + + // Show game elements + gameView.visibility = View.VISIBLE + binding.gameControlsContainer.visibility = View.VISIBLE + binding.holdPieceView.visibility = View.VISIBLE + binding.nextPieceView.visibility = View.VISIBLE + binding.pauseButton.visibility = View.VISIBLE + binding.gameOverContainer.visibility = View.GONE + binding.pauseContainer.visibility = View.GONE + titleScreen.visibility = View.GONE + progressionScreen.visibility = View.GONE + + // Show game UI elements in landscape mode + if (resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) { + binding.leftControlsPanel?.visibility = View.VISIBLE + binding.rightControlsPanel?.visibility = View.VISIBLE + } + + // Configure callbacks + gameView.onGameStateChanged = { score, level, lines -> + updateGameStateUI(score, level, lines) + } + + gameView.onGameOver = { finalScore -> + // Pause music on game over + gameMusic.pause() + + // Update high scores + val timePlayedMs = System.currentTimeMillis() - gameStartTime + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val currentDate = dateFormat.format(Date()) + + // Track if this is a high score + val isHighScore = highScoreManager.isHighScore(finalScore) + + // Show game over screen + showGameOver(finalScore) + + // Save player stats to track game history + statsManager.updateSessionStats(finalScore, gameBoard.lines, piecesPlaced, timePlayedMs, currentLevel) + + // Handle progression - XP earned, potential level up + val xpGained = progressionManager.calculateGameXP( + score = finalScore, + lines = gameBoard.lines, + level = currentLevel, + timePlayedMs = timePlayedMs, + quadCount = statsManager.getSessionQuads(), + perfectClearCount = statsManager.getSessionPerfectClears() + ) + val newRewards = progressionManager.addXP(xpGained) + + // Show progression screen if player earned XP + if (xpGained > 0) { + // Delay showing progression screen for a moment + Handler(Looper.getMainLooper()).postDelayed({ + showProgressionScreen(xpGained, newRewards) + }, 2000) + } + } + + // Connect line clear callback to gamepad rumble + gameView.onLineClear = { lineCount -> + // Vibrate phone + gameHaptics.vibrateForLineClear(lineCount) + + // Vibrate gamepad if connected + gamepadController.vibrateForLineClear(lineCount) + + // Record line clear in stats + statsManager.recordLineClear(lineCount) + } + + // Reset pause button state + binding.pauseStartButton.visibility = View.VISIBLE + binding.resumeButton.visibility = View.GONE + + // Start background music if enabled + if (isMusicEnabled) { + gameMusic.start() + } + + // Start the game + gameView.start() + gameMusic.setEnabled(isMusicEnabled) + + // Reset session stats + statsManager.startNewSession() + progressionManager.startNewSession() + gameBoard.updateLevel(selectedLevel) + } + + private fun restartGame() { + gameBoard.reset() + gameView.visibility = View.VISIBLE + gameView.start() + showPauseMenu() + } + + private fun resumeGame() { + gameView.resume() + if (isMusicEnabled) { + gameMusic.resume() + } + // Force a redraw to ensure pieces aren't frozen + gameView.invalidate() + } + + override fun onPause() { + super.onPause() + if (gameView.visibility == View.VISIBLE) { + gameView.pause() + gameMusic.pause() + } + + // Unregister broadcast receiver + try { + unregisterReceiver(inputDeviceReceiver) + } catch (e: IllegalArgumentException) { + // Receiver wasn't registered + } + } + + override fun onResume() { + super.onResume() + + // Register for input device changes + val filter = IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED) + registerReceiver(inputDeviceReceiver, filter) + + // Check for already connected gamepads + checkGamepadConnections() + + // If we're on the title screen, don't auto-resume the game + if (titleScreen.visibility == View.GONE && gameView.visibility == View.VISIBLE && binding.gameOverContainer.visibility == View.GONE && binding.pauseContainer.visibility == View.GONE) { + resumeGame() + } + + // Update theme selector with available themes when pause screen appears + updateThemeSelector() + } + + override fun onDestroy() { + super.onDestroy() + gameMusic.release() + } + + /** + * Show title screen (for game restart) + */ + private fun showTitleScreen() { + gameView.reset() + gameView.visibility = View.GONE + binding.gameControlsContainer.visibility = View.GONE + binding.gameOverContainer.visibility = View.GONE + binding.pauseContainer.visibility = View.GONE + titleScreen.visibility = View.VISIBLE + titleScreen.applyTheme(currentTheme) + } + + /** + * Show high scores + */ + private fun showHighScores() { + val intent = Intent(this, HighScoresActivity::class.java) + startActivity(intent) + } + + /** + * Update the theme selector with unlocked themes + */ + private fun updateThemeSelector() { + binding.customizationThemeSelector?.updateThemes( + unlockedThemes = progressionManager.getUnlockedThemes(), + currentTheme = currentTheme + ) + } + + /** + * Apply a theme to the game + */ + private fun applyTheme(themeId: String) { + currentTheme = themeId + val 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 + } + + // Get background color for the theme + 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 + } + + // Apply background color to root view + binding.root.setBackgroundColor(backgroundColor) + + // Apply theme color to title screen + titleScreen.setThemeColor(themeColor) + titleScreen.setBackgroundColor(backgroundColor) + + // Apply theme color to game over screen + binding.gameOverContainer.setBackgroundColor(backgroundColor) + binding.gameOverText.setTextColor(themeColor) + binding.scoreText.setTextColor(themeColor) + binding.currentLevelText.setTextColor(themeColor) + binding.linesText.setTextColor(themeColor) + binding.comboText.setTextColor(themeColor) + binding.playAgainButton.setTextColor(themeColor) + binding.playAgainButton.setBackgroundResource(android.R.color.transparent) + binding.playAgainButton.setTextSize(24f) + + // Apply theme to progression screen (it will handle its own colors) + progressionScreen.applyTheme(themeId) + + // Apply theme color to game view + gameView.setThemeColor(themeColor) + gameView.setBackgroundColor(backgroundColor) + + // Save theme preference + progressionManager.setSelectedTheme(themeId) + } + + /** + * Get the appropriate color for the current theme + */ + private fun getThemeColor(themeId: String): Int { + return 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 + } + } + + /** + * Disables the Android system back gesture to prevent accidental exits + */ + private fun disableAndroidBackGesture() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Set the entire window to be excluded from the system gesture areas + window.decorView.post { + // Create a list of rectangles representing the edges of the screen to exclude from system gestures + val gestureInsets = window.decorView.rootWindowInsets?.systemGestureInsets + if (gestureInsets != null) { + val leftEdge = Rect(0, 0, 50, window.decorView.height) + val rightEdge = Rect(window.decorView.width - 50, 0, window.decorView.width, window.decorView.height) + val bottomEdge = Rect(0, window.decorView.height - 50, window.decorView.width, window.decorView.height) + + window.decorView.systemGestureExclusionRects = listOf(leftEdge, rightEdge, bottomEdge) + } + } + } + + // Add an on back pressed callback to handle back button/gesture + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // If we're playing the game, handle it as a pause action instead of exiting + if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { + gameView.pause() + gameMusic.pause() + showPauseMenu() + binding.pauseStartButton.visibility = View.GONE + binding.resumeButton.visibility = View.VISIBLE + } else if (binding.pauseContainer.visibility == View.VISIBLE) { + // If pause menu is showing, handle as a resume + resumeGame() + } else if (binding.gameOverContainer.visibility == View.VISIBLE) { + // If game over is showing, go back to title + hideGameOver() + showTitleScreen() + } else if (titleScreen.visibility == View.VISIBLE) { + // If title screen is showing, allow normal back behavior (exit app) + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + }) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // For Android 11 (R) to Android 12 (S), use the WindowInsetsController to disable gestures + window.insetsController?.systemBarsBehavior = + android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + /** + * GamepadConnectionListener implementation + */ + override fun onGamepadConnected(gamepadName: String) { + runOnUiThread { + Toast.makeText(this, "Gamepad connected: $gamepadName", Toast.LENGTH_SHORT).show() + // Set gamepad connected state in haptics + gameHaptics.setGamepadConnected(true) + // Provide haptic feedback for gamepad connection + gameHaptics.vibrateForPieceLock() + + // Show gamepad help if not shown before + if (!hasSeenGamepadHelp()) { + showGamepadHelpDialog() + } + } + } + + override fun onGamepadDisconnected(gamepadName: String) { + runOnUiThread { + Toast.makeText(this, "Gamepad disconnected: $gamepadName", Toast.LENGTH_SHORT).show() + // Set gamepad disconnected state in haptics + gameHaptics.setGamepadConnected(false) + } + } + + /** + * Show gamepad help dialog + */ + private fun showGamepadHelpDialog() { + val dialogView = layoutInflater.inflate(R.layout.gamepad_help_dialog, null) + val dialog = android.app.AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_NoActionBar) + .setView(dialogView) + .setCancelable(true) + .create() + + // Set up dismiss button + dialogView.findViewById(R.id.gamepad_help_dismiss_button).setOnClickListener { + dialog.dismiss() + markGamepadHelpSeen() + } + + dialog.show() + } + + /** + * Check if user has seen the gamepad help + */ + private fun hasSeenGamepadHelp(): Boolean { + val prefs = getSharedPreferences("com.com.pixelmintgames.pixelmintdrop.preferences", Context.MODE_PRIVATE) + return prefs.getBoolean("has_seen_gamepad_help", false) + } + + /** + * Mark that user has seen the gamepad help + */ + private fun markGamepadHelpSeen() { + val prefs = getSharedPreferences("com.com.pixelmintgames.pixelmintdrop.preferences", Context.MODE_PRIVATE) + prefs.edit().putBoolean("has_seen_gamepad_help", true).apply() + } + + /** + * Check for connected/disconnected gamepads + */ + private fun checkGamepadConnections() { + val currentGamepads = GamepadController.getConnectedGamepadsInfo().toSet() + + // Find newly connected gamepads + currentGamepads.filter { it !in connectedGamepads }.forEach { gamepadName -> + onGamepadConnected(gamepadName) + } + + // Find disconnected gamepads + connectedGamepads.filter { it !in currentGamepads }.forEach { gamepadName -> + onGamepadDisconnected(gamepadName) + } + + // Update the stored list of connected gamepads + connectedGamepads.clear() + connectedGamepads.addAll(currentGamepads) + } + + /** + * Handle key events for both device controls and gamepads + */ + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (event == null) return super.onKeyDown(keyCode, event) + + // First check if it's a gamepad input + if (GamepadController.isGamepad(event.device)) { + // Handle title screen start with gamepad + if (titleScreen.visibility == View.VISIBLE && + (keyCode == KeyEvent.KEYCODE_BUTTON_A || keyCode == KeyEvent.KEYCODE_BUTTON_START)) { + titleScreen.performClick() + return true + } + + // If gamepad input was handled by the controller, consume the event + if (gamepadController.handleKeyEvent(keyCode, event)) { + return true + } + } + + // If not handled as gamepad, handle as regular key event + // Handle back button + if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) { + // Handle back button press as a pause action during gameplay + if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { + gameView.pause() + gameMusic.pause() + showPauseMenu() + binding.pauseStartButton.visibility = View.GONE + binding.resumeButton.visibility = View.VISIBLE + return true // Consume the event + } else if (binding.pauseContainer.visibility == View.VISIBLE) { + // If pause menu is showing, handle as a resume + resumeGame() + return true // Consume the event + } else if (binding.customizationContainer.visibility == View.VISIBLE) { + // If customization menu is showing, hide it + hideCustomizationMenu() + return true // Consume the event + } else if (binding.gameOverContainer.visibility == View.VISIBLE) { + // If game over is showing, go back to title + hideGameOver() + showTitleScreen() + return true // Consume the event + } + } + + return super.onKeyDown(keyCode, event) + } + + /** + * Handle motion events for gamepad analog sticks + */ + override fun onGenericMotionEvent(event: MotionEvent): Boolean { + // Check if it's a gamepad motion event (analog sticks) + if ((event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) { + if (gamepadController.handleMotionEvent(event)) { + return true + } + } + + return super.onGenericMotionEvent(event) + } + + private fun showProgressionScreen(xpGained: Long, newRewards: List) { + // Apply theme before showing the screen + progressionScreen.applyTheme(currentTheme) + + // Hide all game UI elements + binding.gameControlsContainer.visibility = View.GONE + binding.holdPieceView.visibility = View.GONE + binding.nextPieceView.visibility = View.GONE + binding.pauseButton.visibility = View.GONE + + // Hide landscape panels if they exist + binding.leftControlsPanel?.visibility = View.GONE + binding.rightControlsPanel?.visibility = View.GONE + + // Show the progression screen + binding.gameOverContainer.visibility = View.GONE + progressionScreen.visibility = View.VISIBLE + + // Display progression data + progressionScreen.showProgress(progressionManager, xpGained, newRewards, currentTheme) + } + + /** + * Implements GamepadMenuListener to handle start button press + */ + override fun onPauseRequested() { + runOnUiThread { + if (binding.customizationContainer.visibility == View.VISIBLE) { + // If customization menu is showing, hide it + hideCustomizationMenu() + } else if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { + gameView.pause() + gameMusic.pause() + showPauseMenu() + binding.pauseStartButton.visibility = View.GONE + binding.resumeButton.visibility = View.VISIBLE + } else if (binding.pauseContainer.visibility == View.VISIBLE) { + // If pause menu is showing, handle as a resume + hidePauseMenu() + resumeGame() + } else if (titleScreen.visibility == View.VISIBLE) { + // If title screen is showing, start the game + titleScreen.performClick() + } + } + } + + /** + * Implements GamepadNavigationListener to handle menu navigation + */ + override fun onMenuUp() { + runOnUiThread { + if (binding.pauseContainer.visibility == View.VISIBLE) { + moveMenuSelectionUp() + scrollToSelectedItem() + } else if (binding.customizationContainer.visibility == View.VISIBLE) { + moveCustomizationMenuSelectionUp() + scrollToSelectedCustomizationItem() + } + } + } + + override fun onMenuDown() { + runOnUiThread { + if (binding.pauseContainer.visibility == View.VISIBLE) { + moveMenuSelectionDown() + scrollToSelectedItem() + } else if (binding.customizationContainer.visibility == View.VISIBLE) { + moveCustomizationMenuSelectionDown() + scrollToSelectedCustomizationItem() + } + } + } + + override fun onMenuLeft() { + runOnUiThread { + if (binding.pauseContainer.visibility == View.VISIBLE && pauseMenuItems.isNotEmpty()) { + val selectedItem = pauseMenuItems.getOrNull(currentMenuSelection) + when (selectedItem) { + is ThemeSelector -> selectedItem.focusPreviousItem() + is BlockSkinSelector -> selectedItem.focusPreviousItem() + } + } else if (binding.customizationContainer.visibility == View.VISIBLE && customizationMenuItems.isNotEmpty()) { + val selectedItem = customizationMenuItems.getOrNull(currentCustomizationMenuSelection) + when (selectedItem) { + is ThemeSelector -> selectedItem.focusPreviousItem() + is BlockSkinSelector -> selectedItem.focusPreviousItem() + } + } + } + } + + override fun onMenuRight() { + runOnUiThread { + if (binding.pauseContainer.visibility == View.VISIBLE && pauseMenuItems.isNotEmpty()) { + val selectedItem = pauseMenuItems.getOrNull(currentMenuSelection) + when (selectedItem) { + is ThemeSelector -> selectedItem.focusNextItem() + is BlockSkinSelector -> selectedItem.focusNextItem() + } + } else if (binding.customizationContainer.visibility == View.VISIBLE && customizationMenuItems.isNotEmpty()) { + val selectedItem = customizationMenuItems.getOrNull(currentCustomizationMenuSelection) + when (selectedItem) { + is ThemeSelector -> selectedItem.focusNextItem() + is BlockSkinSelector -> selectedItem.focusNextItem() + } + } + } + } + + override fun onMenuSelect() { + runOnUiThread { + if (binding.pauseContainer.visibility == View.VISIBLE && pauseMenuItems.isNotEmpty()) { + activateSelectedMenuItem() + } else if (binding.customizationContainer.visibility == View.VISIBLE && customizationMenuItems.isNotEmpty()) { + activateSelectedCustomizationMenuItem() + } else if (titleScreen.visibility == View.VISIBLE) { + titleScreen.performClick() + } else if (progressionScreen.visibility == View.VISIBLE) { + progressionScreen.performContinue() + } else if (binding.gameOverContainer.visibility == View.VISIBLE) { + binding.playAgainButton.performClick() + } + } + } + + /** + * Initialize pause menu items for gamepad navigation + */ + private fun initPauseMenuNavigation() { + pauseMenuItems.clear() + + // Check landscape mode only to select the *correct instance* of the view + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + // Define the unified order (Portrait Order) + val orderedViews = mutableListOf() + + // Group 1: Game control buttons (Resume/Start New Game, Restart) + if (binding.resumeButton.visibility == View.VISIBLE) { + orderedViews.add(binding.resumeButton) + } + if (binding.pauseStartButton.visibility == View.VISIBLE) { + orderedViews.add(binding.pauseStartButton) + } + orderedViews.add(binding.pauseRestartButton) + + // Group 2: Stats and Scoring + orderedViews.add(binding.highScoresButton) + orderedViews.add(binding.statsButton) + + // Group 3: Level selection (use safe calls) + orderedViews.add(binding.pauseLevelUpButton) + orderedViews.add(binding.pauseLevelDownButton) + + // Group 4: Sound and Music controls (Moved UP) - Use safe calls + orderedViews.add(binding.settingsButton) // Sound toggle (Button) + orderedViews.add(binding.musicToggle) // Music toggle (ImageButton) + + // Group 5: Customization Menu + orderedViews.add(binding.customizationButton) // Add the customization button + + // Add all non-null, visible items from the defined order to the final navigation list + pauseMenuItems.addAll(orderedViews.filterNotNull().filter { it.visibility == View.VISIBLE }) + } + + /** + * Highlight the currently selected menu item + */ + private fun highlightMenuItem(index: Int) { + // Ensure index is valid + if (pauseMenuItems.isEmpty() || index < 0 || index >= pauseMenuItems.size) { + Log.w(TAG, "highlightMenuItem called with invalid index: $index or empty items") + return + } + + val themeColor = getThemeColor(currentTheme) + val highlightBorderColor = themeColor // Use theme color for the main highlight border + + pauseMenuItems.forEachIndexed { i, item -> + val isSelected = (i == index) + + // Reset common properties + item.alpha = if (isSelected) 1.0f else 0.7f + if (item is Button || item is ImageButton) { + item.scaleX = 1.0f + item.scaleY = 1.0f + } + // Reset + when (item) { + is Button -> item.background = ColorDrawable(Color.TRANSPARENT) + is ImageButton -> item.background = null // Let default background handle non-selected + is ThemeSelector -> { + item.background = ColorDrawable(Color.TRANSPARENT) + item.setHasFocus(false) // Explicitly remove component focus + } + is BlockSkinSelector -> { + item.background = ColorDrawable(Color.TRANSPARENT) + item.setHasFocus(false) // Explicitly remove component focus + } + } + + // Apply highlight to the selected item + if (isSelected) { + // Apply scaling to buttons + if (item is Button || item is ImageButton) { + item.scaleX = 1.1f + item.scaleY = 1.1f + } + + // Add border/background based on type + val borderWidth = 4 // Unified border width for highlight + val cornerRadius = 12f + + when (item) { + is Button -> { + // Attempt to use a themed drawable, fallback to simple border + ContextCompat.getDrawable(this, R.drawable.menu_item_selected)?.mutate()?.let { + if (it is GradientDrawable) { + it.setStroke(borderWidth, highlightBorderColor) + item.background = it + } else { + // Fallback for non-gradient drawable (e.g., tinting) + it.setTint(highlightBorderColor) + item.background = it + } + } ?: run { + // Fallback to simple border if drawable not found + var gradientDrawable = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(borderWidth, highlightBorderColor) + setCornerRadius(cornerRadius) + } + item.background = gradientDrawable + } + } + is ImageButton -> { + // Similar handling for ImageButton + ContextCompat.getDrawable(this, R.drawable.menu_item_selected)?.mutate()?.let { + if (it is GradientDrawable) { + it.setStroke(borderWidth, highlightBorderColor) + item.background = it + } else { + it.setTint(highlightBorderColor) + item.background = it + } + } ?: run { + var gradientDrawable = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(borderWidth, highlightBorderColor) + setCornerRadius(cornerRadius) + } + item.background = gradientDrawable + } + } + } + } + } + + // Provide haptic feedback for selection change + gameHaptics.vibrateForPieceMove() + + // Ensure the selected item is visible + scrollToSelectedItem() + } + + /** + * Move menu selection up + */ + private fun moveMenuSelectionUp() { + if (pauseMenuItems.isEmpty()) return + + // Calculate new selection index with wrapping + val previousSelection = currentMenuSelection + currentMenuSelection = (currentMenuSelection - 1) + + // Prevent wrapping from bottom to top for more intuitive navigation + if (currentMenuSelection < 0) { + currentMenuSelection = 0 + return // Already at top, don't provide feedback + } + + // Only provide feedback if selection actually changed + if (previousSelection != currentMenuSelection) { + highlightMenuItem(currentMenuSelection) + } + } + + /** + * Move menu selection down + */ + private fun moveMenuSelectionDown() { + if (pauseMenuItems.isEmpty()) return + + // Calculate new selection index with wrapping + val previousSelection = currentMenuSelection + currentMenuSelection = (currentMenuSelection + 1) + + // Prevent wrapping from bottom to top for more intuitive navigation + if (currentMenuSelection >= pauseMenuItems.size) { + currentMenuSelection = pauseMenuItems.size - 1 + return // Already at bottom, don't provide feedback + } + + // Only provide feedback if selection actually changed + if (previousSelection != currentMenuSelection) { + highlightMenuItem(currentMenuSelection) + } + } + + /** + * Scroll the selected menu item into view + */ + private fun scrollToSelectedItem() { + if (pauseMenuItems.isEmpty() || currentMenuSelection < 0 || currentMenuSelection >= pauseMenuItems.size) return + + val selectedItem = pauseMenuItems[currentMenuSelection] + + // Determine which scroll view to use based on orientation + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + val scrollView = if (isLandscape) { + findLandscapeScrollView() + } else { + binding.pauseMenuScrollView + } ?: return + + // Delay the scrolling slightly to ensure measurements are correct + scrollView.post { + try { + // Calculate item's position in the ScrollView's coordinate system + val scrollBounds = Rect() + scrollView.getHitRect(scrollBounds) + + // Get item's location on screen + val itemLocation = IntArray(2) + selectedItem.getLocationOnScreen(itemLocation) + + // Get ScrollView's location on screen + val scrollLocation = IntArray(2) + scrollView.getLocationOnScreen(scrollLocation) + + // Calculate relative position + val itemY = itemLocation[1] - scrollLocation[1] + + // Get the top and bottom of the item relative to the ScrollView + val itemTop = itemY + val itemBottom = itemY + selectedItem.height + + // Get the visible height of the ScrollView + val visibleHeight = scrollView.height + + // Add padding for better visibility + val padding = 50 + + // Calculate the scroll target position + val scrollY = scrollView.scrollY + + // If item is above visible area + if (itemTop < padding) { + scrollView.smoothScrollTo(0, scrollY + (itemTop - padding)) + } + // If item is below visible area + else if (itemBottom > visibleHeight - padding) { + scrollView.smoothScrollTo(0, scrollY + (itemBottom - visibleHeight + padding)) + } + } catch (e: Exception) { + // Handle any exceptions that might occur during scrolling + Log.e(TAG, "Error scrolling to selected item", e) + } + } + } + + /** + * Activate the currently selected menu item + */ + private fun activateSelectedMenuItem() { + if (pauseMenuItems.isEmpty() || currentMenuSelection < 0 || currentMenuSelection >= pauseMenuItems.size) return + + val selectedItem = pauseMenuItems[currentMenuSelection] + + // Vibrate regardless of item type for consistent feedback + gameHaptics.vibrateForPieceLock() + + when (selectedItem) { + is ThemeSelector -> { + // Confirm the internally focused theme + selectedItem.confirmSelection() + // Applying the theme might change colors, so refresh the pause menu display + // Need to delay slightly to allow theme application to potentially finish + Handler(Looper.getMainLooper()).postDelayed({ showPauseMenu() }, 100) + } + is BlockSkinSelector -> { + // Confirm the internally focused block skin + selectedItem.confirmSelection() + // Skin change doesn't affect menu colors, no refresh needed here + } + else -> { + // Handle other menu items with a standard click + selectedItem.performClick() + } + } + } + + /** + * Find any ScrollView in the pause container for landscape mode + */ + private fun findLandscapeScrollView(): ScrollView? { + // First try to find a ScrollView directly in the landscape layout + val pauseContainer = findViewById(R.id.pauseContainer) ?: return null + + // Try to find any ScrollView in the pause container + if (pauseContainer is android.view.ViewGroup) { + // Search for ScrollView in the container + fun findScrollView(view: android.view.View): ScrollView? { + if (view is ScrollView && view.visibility == View.VISIBLE) { + return view + } else if (view is android.view.ViewGroup) { + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + val scrollView = findScrollView(child) + if (scrollView != null) { + return scrollView + } + } + } + return null + } + + // Try to find ScrollView in the container + val scrollView = findScrollView(pauseContainer) + if (scrollView != null && scrollView != binding.pauseMenuScrollView) { + return scrollView + } + } + + // If no landscape ScrollView is found, fall back to the default one + return binding.pauseMenuScrollView + } + + // Add these new methods for customization menu navigation + private fun moveCustomizationMenuSelectionUp() { + if (customizationMenuItems.isEmpty()) return + + val previousSelection = currentCustomizationMenuSelection + currentCustomizationMenuSelection = (currentCustomizationMenuSelection - 1) + + if (currentCustomizationMenuSelection < 0) { + currentCustomizationMenuSelection = 0 + return + } + + if (previousSelection != currentCustomizationMenuSelection) { + highlightCustomizationMenuItem(currentCustomizationMenuSelection) + } + } + + private fun moveCustomizationMenuSelectionDown() { + if (customizationMenuItems.isEmpty()) return + + val previousSelection = currentCustomizationMenuSelection + currentCustomizationMenuSelection = (currentCustomizationMenuSelection + 1) + + if (currentCustomizationMenuSelection >= customizationMenuItems.size) { + currentCustomizationMenuSelection = customizationMenuItems.size - 1 + return + } + + if (previousSelection != currentCustomizationMenuSelection) { + highlightCustomizationMenuItem(currentCustomizationMenuSelection) + } + } + + private fun highlightCustomizationMenuItem(index: Int) { + if (customizationMenuItems.isEmpty() || index < 0 || index >= customizationMenuItems.size) { + Log.w(TAG, "highlightCustomizationMenuItem called with invalid index: $index or empty items") + return + } + + val themeColor = getThemeColor(currentTheme) + val highlightBorderColor = themeColor + val borderWidth = 4 // Define border width for highlight + val cornerRadius = 12f + + customizationMenuItems.forEachIndexed { i, item -> + val isSelected = (i == index) + + item.alpha = if (isSelected) 1.0f else 0.7f + if (item is Button || item is ImageButton) { + item.scaleX = 1.0f + item.scaleY = 1.0f + } + + when (item) { + is Button -> item.background = ColorDrawable(Color.TRANSPARENT) + is ImageButton -> item.background = null + is ThemeSelector -> { + item.background = ColorDrawable(Color.TRANSPARENT) + item.setHasFocus(false) + } + is BlockSkinSelector -> { + item.background = ColorDrawable(Color.TRANSPARENT) + item.setHasFocus(false) + } + is Switch -> { + item.background = ColorDrawable(Color.TRANSPARENT) + } + } + + if (isSelected) { + when (item) { + is Button -> { + var gradientDrawable = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(borderWidth, highlightBorderColor) + setCornerRadius(cornerRadius) + } + item.background = gradientDrawable + } + is ImageButton -> { + var gradientDrawable = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(borderWidth, highlightBorderColor) + setCornerRadius(cornerRadius) + } + item.background = gradientDrawable + } + is ThemeSelector -> { + var gradientDrawable = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(borderWidth, highlightBorderColor) + setCornerRadius(cornerRadius) + } + item.background = gradientDrawable + item.setHasFocus(true) + } + is BlockSkinSelector -> { + var gradientDrawable = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(borderWidth, highlightBorderColor) + setCornerRadius(cornerRadius) + } + item.background = gradientDrawable + item.setHasFocus(true) + } + is Switch -> { + var gradientDrawable = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(borderWidth, highlightBorderColor) + setCornerRadius(cornerRadius) + } + item.background = gradientDrawable + } + } + } + } + + gameHaptics.vibrateForPieceMove() + scrollToSelectedCustomizationItem() + } + + private fun scrollToSelectedCustomizationItem() { + if (customizationMenuItems.isEmpty() || currentCustomizationMenuSelection < 0 || currentCustomizationMenuSelection >= customizationMenuItems.size) return + + val selectedItem = customizationMenuItems[currentCustomizationMenuSelection] + val scrollView = binding.customizationMenuScrollView + + scrollView.post { + try { + val scrollBounds = Rect() + scrollView.getHitRect(scrollBounds) + + val itemLocation = IntArray(2) + selectedItem.getLocationOnScreen(itemLocation) + + val scrollLocation = IntArray(2) + scrollView.getLocationOnScreen(scrollLocation) + + val itemY = itemLocation[1] - scrollLocation[1] + val itemTop = itemY + val itemBottom = itemY + selectedItem.height + val visibleHeight = scrollView.height + val padding = 50 + val scrollY = scrollView.scrollY + + if (itemTop < padding) { + scrollView.smoothScrollTo(0, scrollY + (itemTop - padding)) + } else if (itemBottom > visibleHeight - padding) { + scrollView.smoothScrollTo(0, scrollY + (itemBottom - visibleHeight + padding)) + } + } catch (e: Exception) { + Log.e(TAG, "Error scrolling to selected customization item", e) + } + } + } + + private fun activateSelectedCustomizationMenuItem() { + if (customizationMenuItems.isEmpty() || currentCustomizationMenuSelection < 0 || currentCustomizationMenuSelection >= customizationMenuItems.size) return + + val selectedItem = customizationMenuItems[currentCustomizationMenuSelection] + gameHaptics.vibrateForPieceLock() + + when (selectedItem) { + is ThemeSelector -> { + selectedItem.confirmSelection() + Handler(Looper.getMainLooper()).postDelayed({ showCustomizationMenu() }, 100) + } + is BlockSkinSelector -> { + selectedItem.confirmSelection() + // The onBlockSkinSelected callback will handle updating the game view and saving the selection + } + is Switch -> { + if (selectedItem.isEnabled) { + selectedItem.isChecked = !selectedItem.isChecked + } + } + else -> { + selectedItem.performClick() + } + } + } + + private fun updateGameStateUI(score: Int, level: Int, lines: Int) { + currentScore = score.toLong() + currentLevel = level + + binding.scoreText.text = "$score" + binding.currentLevelText.text = "$level" + binding.linesText.text = "$lines" + binding.comboText.text = gameBoard.getCombo().toString() + + // If random mode is enabled, check if we should change theme or block skin + if (isRandomModeEnabled) { + // Get the current 10-line group (0 for 0-9, 1 for 10-19, etc.) + val currentLinesGroup = lines / 10 + + // Determine if we should change themes and skins: + // 1. If we've moved to a new 10-line group + // 2. If we've leveled up to a level we haven't applied random to yet + val lineGroupChanged = lines > 0 && currentLinesGroup > lastLinesGroup + val levelIncreased = level > 1 && level > lastRandomLevel + + if (lineGroupChanged || levelIncreased) { + Log.d("RandomMode", "Triggering change - Lines: $lines (group $currentLinesGroup, was $lastLinesGroup), Level: $level (was $lastRandomLevel)") + applyRandomThemeAndBlockSkin() + + // Update tracking variables + lastLinesGroup = currentLinesGroup + lastRandomLevel = level + } + } + + // Update last lines count + lastLines = lines + } + + private fun applyRandomThemeAndBlockSkin() { + val unlockedThemes = progressionManager.getUnlockedThemes().toList() + val unlockedBlocks = progressionManager.getUnlockedBlocks().toList() + + // Log available options + Log.d("RandomMode", "Available themes: ${unlockedThemes.joinToString()}") + Log.d("RandomMode", "Available blocks: ${unlockedBlocks.joinToString()}") + + // Get current block skin for debugging + val currentBlockSkin = gameView.getCurrentBlockSkin() + Log.d("RandomMode", "Current block skin before change: $currentBlockSkin") + + // Only proceed if there are unlocked themes and blocks + if (unlockedThemes.isNotEmpty() && unlockedBlocks.isNotEmpty()) { + // Apply random theme from unlocked themes - make sure not to pick the current theme + val availableThemes = unlockedThemes.filter { it != currentTheme } + val randomTheme = if (availableThemes.isNotEmpty()) { + availableThemes.random() + } else { + unlockedThemes.random() + } + + // Apply random block skin from unlocked block skins - make sure not to pick the current skin + val availableBlocks = unlockedBlocks.filter { it != currentBlockSkin } + val randomBlock = if (availableBlocks.isNotEmpty()) { + availableBlocks.random() + } else { + unlockedBlocks.random() + } + + Log.d("RandomMode", "Applying random theme: $randomTheme and block skin: $randomBlock") + + // Apply the theme + currentTheme = randomTheme + applyTheme(randomTheme) + + // Force update the block skin with a specific call - with intentional delay + handler.post { + Log.d("RandomMode", "Setting block skin to: $randomBlock") + gameView.setBlockSkin(randomBlock) + progressionManager.setSelectedBlockSkin(randomBlock) + + // Get current block skin after change for debugging + val newBlockSkin = gameView.getCurrentBlockSkin() + Log.d("RandomMode", "Block skin after change: $newBlockSkin") + + // Update the UI to reflect the changes + themeSelector.updateThemes(progressionManager.getUnlockedThemes(), currentTheme) + blockSkinSelector.updateBlockSkins( + progressionManager.getUnlockedBlocks(), + randomBlock, + progressionManager.getPlayerLevel() + ) + + // Add a vibration to indicate the change to the player + gameHaptics.vibrateForPieceLock() + } + } else { + Log.d("RandomMode", "Cannot apply random theme/skin - no unlocked options available") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/StatsActivity.kt b/app/src/main/java/com/pixelmintdrop/StatsActivity.kt similarity index 93% rename from app/src/main/java/com/mintris/StatsActivity.kt rename to app/src/main/java/com/pixelmintdrop/StatsActivity.kt index c7d2e0e..1652365 100644 --- a/app/src/main/java/com/mintris/StatsActivity.kt +++ b/app/src/main/java/com/pixelmintdrop/StatsActivity.kt @@ -1,13 +1,11 @@ -package com.mintris +package com.pixelmintdrop 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 com.pixelmintdrop.databinding.ActivityStatsBinding +import com.pixelmintdrop.model.StatsManager +import com.pixelmintdrop.model.PlayerProgressionManager import android.graphics.Color import java.text.SimpleDateFormat import java.util.* @@ -80,7 +78,7 @@ class StatsActivity : AppCompatActivity() { binding.totalSinglesText.setTextColor(textColor) binding.totalDoublesText.setTextColor(textColor) binding.totalTriplesText.setTextColor(textColor) - binding.totalTetrisesText.setTextColor(textColor) + binding.totalQuadsText.setTextColor(textColor) binding.maxLevelText.setTextColor(textColor) binding.maxScoreText.setTextColor(textColor) binding.maxLinesText.setTextColor(textColor) @@ -118,7 +116,7 @@ class StatsActivity : AppCompatActivity() { 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()) + binding.totalQuadsText.text = getString(R.string.quads, statsManager.getTotalQuads()) // Update best performance stats binding.maxLevelText.text = getString(R.string.max_level, statsManager.getMaxLevel()) diff --git a/app/src/main/java/com/pixelmintdrop/ThemeManager.kt b/app/src/main/java/com/pixelmintdrop/ThemeManager.kt new file mode 100644 index 0000000..b2d1e1f --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/ThemeManager.kt @@ -0,0 +1,19 @@ +package com.pixelmintdrop + +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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/accessibility/GameAccessibilityHelper.kt b/app/src/main/java/com/pixelmintdrop/accessibility/GameAccessibilityHelper.kt new file mode 100644 index 0000000..45281c0 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/accessibility/GameAccessibilityHelper.kt @@ -0,0 +1,152 @@ +package com.pixelmintdrop.accessibility + +import android.content.Context +import android.os.Build +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager +import android.widget.ImageButton +import android.widget.TextView +import androidx.core.view.ViewCompat +import com.pixelmintdrop.R +import com.pixelmintdrop.game.GameView +import com.pixelmintdrop.model.GamePieceType + +/** + * Helper class to improve the game's accessibility for users with visual impairments + * or other accessibility needs. + */ +class GameAccessibilityHelper(private val context: Context) { + + private val accessibilityManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + + /** + * Sets up accessibility content descriptions for game controls + */ + fun setupAccessibilityDescriptions( + leftButton: ImageButton?, + rightButton: ImageButton?, + rotateButton: ImageButton?, + dropButton: ImageButton?, + holdButton: ImageButton?, + pauseButton: ImageButton?, + gameView: GameView?, + holdPieceView: View?, + nextPieceView: View? + ) { + // Set content descriptions for all control buttons + leftButton?.contentDescription = context.getString(R.string.accessibility_move_left) + rightButton?.contentDescription = context.getString(R.string.accessibility_move_right) + rotateButton?.contentDescription = context.getString(R.string.accessibility_rotate_piece) + dropButton?.contentDescription = context.getString(R.string.accessibility_drop_piece) + holdButton?.contentDescription = context.getString(R.string.accessibility_hold_piece) + pauseButton?.contentDescription = context.getString(R.string.accessibility_pause_game) + + // Set content descriptions for game views + gameView?.contentDescription = context.getString(R.string.accessibility_game_board) + holdPieceView?.contentDescription = context.getString(R.string.accessibility_held_piece) + nextPieceView?.contentDescription = context.getString(R.string.accessibility_next_piece) + + // Add accessibility delegate to the game view for custom events + gameView?.let { + ViewCompat.setAccessibilityDelegate(it, object : androidx.core.view.AccessibilityDelegateCompat() { + override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) { + super.onPopulateAccessibilityEvent(host, event) + + // Add custom content to accessibility events if needed + if (event.eventType == AccessibilityEvent.TYPE_VIEW_SELECTED) { + // Example: announce the current piece type + event.text.add("Current piece: ${getCurrentPieceDescription(it)}") + } + } + }) + } + } + + /** + * Announces important game events via accessibility services + */ + fun announceGameEvent(view: View, message: String) { + if (accessibilityManager.isEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.announceForAccessibility(message) + } else { + // For older Android versions + val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT) + event.text.add(message) + view.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT) + } + } + } + + /** + * Updates score and level information to be more accessible + */ + fun updateGameStatusAccessibility( + scoreTextView: TextView?, + levelTextView: TextView?, + linesTextView: TextView?, + score: Long, + level: Int, + lines: Int + ) { + // Make sure score is accessible to screen readers with proper formatting + scoreTextView?.let { + it.contentDescription = "Score: $score points" + } + + levelTextView?.let { + it.contentDescription = "Level: $level" + } + + linesTextView?.let { + it.contentDescription = "Lines cleared: $lines" + } + } + + /** + * Get a description of the current Tetromino piece for accessibility announcements + */ + private fun getCurrentPieceDescription(gameView: GameView): String { + val pieceType = gameView.getCurrentPieceType() ?: return "No piece" + + return when (pieceType) { + GamePieceType.I -> "I piece, long bar" + GamePieceType.J -> "J piece, hook shape pointing left" + GamePieceType.L -> "L piece, hook shape pointing right" + GamePieceType.O -> "O piece, square shape" + GamePieceType.S -> "S piece, zigzag shape" + GamePieceType.T -> "T piece, T shape" + GamePieceType.Z -> "Z piece, reverse zigzag shape" + } + } + + /** + * Announce the game over event with final score + */ + fun announceGameOver(view: View, score: Long) { + announceGameEvent(view, "Game over. Final score: $score points") + } + + /** + * Announce level up + */ + fun announceLevelUp(view: View, newLevel: Int) { + announceGameEvent(view, "Level up! Now at level $newLevel") + } + + /** + * Get the piece name for accessibility announcements + */ + private fun getPieceName(type: GamePieceType): String { + return when (type) { + GamePieceType.I -> "I piece" + GamePieceType.J -> "J piece" + GamePieceType.L -> "L piece" + GamePieceType.O -> "O piece" + GamePieceType.S -> "S piece" + GamePieceType.T -> "T piece" + GamePieceType.Z -> "Z piece" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/audio/GameMusic.kt b/app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt similarity index 58% rename from app/src/main/java/com/mintris/audio/GameMusic.kt rename to app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt index fbb8abf..38a8615 100644 --- a/app/src/main/java/com/mintris/audio/GameMusic.kt +++ b/app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt @@ -1,20 +1,22 @@ -package com.mintris.audio +package com.pixelmintdrop.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 +import com.pixelmintdrop.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}") } @@ -46,6 +48,49 @@ class GameMusic(private val context: Context) { } } + 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 { @@ -103,14 +148,25 @@ class GameMusic(private val context: Context) { fun isEnabled(): Boolean = isEnabled - fun release() { + /** + * Releases all media player resources to prevent memory leaks + */ + fun releaseResources() { try { - Log.d("GameMusic", "Releasing MediaPlayer") + Log.d("GameMusic", "Releasing MediaPlayer resources") mediaPlayer?.release() + gameOverPlayer?.release() mediaPlayer = null + gameOverPlayer = null isPrepared = false } catch (e: Exception) { - Log.e("GameMusic", "Error releasing music: ${e.message}") + Log.e("GameMusic", "Error releasing music resources: ${e.message}") } } + + // Keeping old method for backward compatibility + @Deprecated("Use releaseResources() instead", ReplaceWith("releaseResources()")) + fun release() { + releaseResources() + } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/game/GameHaptics.kt b/app/src/main/java/com/pixelmintdrop/game/GameHaptics.kt new file mode 100644 index 0000000..7629065 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/game/GameHaptics.kt @@ -0,0 +1,136 @@ +package com.pixelmintdrop.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() // Quad: 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 // Quad: 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/game/GameLifecycleManager.kt b/app/src/main/java/com/pixelmintdrop/game/GameLifecycleManager.kt new file mode 100644 index 0000000..f998875 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/game/GameLifecycleManager.kt @@ -0,0 +1,154 @@ +package com.pixelmintdrop.game + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import com.google.gson.Gson +import com.pixelmintdrop.audio.GameMusic +import com.pixelmintdrop.model.GameBoard +import com.pixelmintdrop.model.HighScoreManager +import com.pixelmintdrop.model.StatsManager + +/** + * Handles the game's lifecycle events to ensure proper resource management + * and state saving/restoration. + */ +class GameLifecycleManager(private val context: Context) { + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences("com.com.pixelmintgames.pixelmintdrop.game_state", Context.MODE_PRIVATE) + private val gson = Gson() + + /** + * Save the current game state when the app is paused + */ + fun saveGameState( + gameBoard: GameBoard, + currentScore: Long, + currentLevel: Int, + piecesPlaced: Int, + gameStartTime: Long + ) { + val editor = sharedPreferences.edit() + + // Save primitive data + editor.putLong("current_score", currentScore) + editor.putInt("current_level", currentLevel) + editor.putInt("pieces_placed", piecesPlaced) + editor.putLong("game_start_time", gameStartTime) + + // Save complex GameBoard state - we don't serialize the GameBoard directly + // Instead we save key properties that can be used to recreate the board state + try { + editor.putInt("board_level", gameBoard.level) + editor.putInt("board_lines", gameBoard.lines) + editor.putInt("board_score", gameBoard.score) + } catch (e: Exception) { + // If serialization fails, just clear the saved state + editor.remove("board_level") + editor.remove("board_lines") + editor.remove("board_score") + } + + editor.apply() + } + + /** + * Restore the saved game state when the app is resumed + * Returns a bundle with the restored state or null if no state exists + */ + fun restoreGameState(): Bundle? { + // Check if we have a saved state + if (!sharedPreferences.contains("current_score")) { + return null + } + + val bundle = Bundle() + + // Restore primitive data + bundle.putLong("current_score", sharedPreferences.getLong("current_score", 0)) + bundle.putInt("current_level", sharedPreferences.getInt("current_level", 1)) + bundle.putInt("pieces_placed", sharedPreferences.getInt("pieces_placed", 0)) + bundle.putLong("game_start_time", sharedPreferences.getLong("game_start_time", 0)) + + // We don't try to deserialize the GameBoard, as it's too complex + // Instead, we'll create a new game board and apply saved properties later + bundle.putInt("board_level", sharedPreferences.getInt("board_level", 1)) + bundle.putInt("board_lines", sharedPreferences.getInt("board_lines", 0)) + bundle.putInt("board_score", sharedPreferences.getInt("board_score", 0)) + + return bundle + } + + /** + * Clears the saved game state + */ + fun clearGameState() { + sharedPreferences.edit().clear().apply() + } + + /** + * Handle activity pause + */ + fun onPause( + gameView: GameView?, + gameMusic: GameMusic?, + statsManager: StatsManager?, + highScoreManager: HighScoreManager? + ) { + // Pause the game view + gameView?.let { + if (!it.isPaused && !it.isGameOver()) { + it.pause() + } + } + + // Pause music + gameMusic?.pause() + + // Save any pending stats and scores - these methods must be made public in their respective classes + // or this functionality should be removed if those methods can't be accessed + try { + statsManager?.let { /* Call public save method if available */ } + highScoreManager?.let { /* Call public save method if available */ } + } catch (e: Exception) { + // Log error but continue + } + } + + /** + * Handle activity resume + */ + fun onResume( + gameView: GameView?, + gameMusic: GameMusic?, + isMusicEnabled: Boolean + ) { + // Resume music if enabled + if (isMusicEnabled) { + gameMusic?.resume() + } + } + + /** + * Handle activity destroy + */ + fun onDestroy( + gameView: GameView?, + gameMusic: GameMusic?, + statsManager: StatsManager?, + highScoreManager: HighScoreManager? + ) { + // Release resources + gameView?.releaseResources() + gameMusic?.releaseResources() + + // Save any pending data - these methods must be made public in their respective classes + // or this functionality should be removed if those methods can't be accessed + try { + statsManager?.let { /* Call public save method if available */ } + highScoreManager?.let { /* Call public save method if available */ } + } catch (e: Exception) { + // Log error but continue + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/GameView.kt b/app/src/main/java/com/pixelmintdrop/game/GameView.kt similarity index 56% rename from app/src/main/java/com/mintris/game/GameView.kt rename to app/src/main/java/com/pixelmintdrop/game/GameView.kt index 27e38a0..2a062ee 100644 --- a/app/src/main/java/com/mintris/game/GameView.kt +++ b/app/src/main/java/com/pixelmintdrop/game/GameView.kt @@ -1,4 +1,4 @@ -package com.mintris.game +package com.pixelmintdrop.game import android.animation.ValueAnimator import android.content.Context @@ -20,14 +20,19 @@ import android.view.View import android.view.animation.LinearInterpolator import android.hardware.display.DisplayManager import android.view.Display -import com.mintris.model.GameBoard -import com.mintris.model.Tetromino -import com.mintris.model.TetrominoType +import com.pixelmintdrop.model.GameBoard +import com.pixelmintdrop.model.GamePiece +import com.pixelmintdrop.model.GamePieceType +import com.pixelmintdrop.model.PlayerProgressionManager +import com.pixelmintdrop.model.StatsManager +import com.pixelmintdrop.model.ThemeManager +import com.pixelmintdrop.model.ThemeManager.Theme +import com.pixelmintdrop.model.ThemeManager.ThemeType +import com.pixelmintdrop.model.ThemeManager.ThemeVariant import kotlin.math.abs -import kotlin.math.min /** - * GameView that renders the Tetris game and handles touch input + * GameView that renders the block-stacking game and handles touch input */ class GameView @JvmOverloads constructor( context: Context, @@ -45,9 +50,13 @@ class GameView @JvmOverloads constructor( // Game state private var isRunning = false - var isPaused = false // Changed from private to public to allow access from MainActivity + var isPaused = false private var score = 0 + // Current piece + private var currentPiece: GamePiece? = null + private var nextPiece: GamePiece? = null + // Callbacks var onNextPieceChanged: (() -> Unit)? = null @@ -57,24 +66,33 @@ class GameView @JvmOverloads constructor( isAntiAlias = true } + private val borderGlowPaint = Paint().apply { + color = Color.WHITE + alpha = 60 + isAntiAlias = true + style = Paint.Style.STROKE + strokeWidth = 2f + maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER) + } + private val ghostBlockPaint = Paint().apply { color = Color.WHITE - alpha = 80 // 30% opacity + alpha = 80 isAntiAlias = true } private val gridPaint = Paint().apply { - color = Color.parseColor("#222222") // Very dark gray - alpha = 20 // Reduced from 40 to be more subtle + color = Color.parseColor("#222222") + alpha = 20 isAntiAlias = true strokeWidth = 1f style = Paint.Style.STROKE - maskFilter = null // Ensure no blur effect on grid lines + maskFilter = null } private val glowPaint = Paint().apply { color = Color.WHITE - alpha = 40 // Reduced from 80 for more subtlety + alpha = 40 isAntiAlias = true style = Paint.Style.STROKE strokeWidth = 1.5f @@ -89,33 +107,20 @@ class GameView @JvmOverloads constructor( maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER) } - private val borderGlowPaint = Paint().apply { - color = Color.WHITE - alpha = 60 - isAntiAlias = true - style = Paint.Style.STROKE - strokeWidth = 2f - maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER) - } - - // Add a new paint for the pulse effect private val pulsePaint = Paint().apply { color = Color.CYAN alpha = 255 isAntiAlias = true style = Paint.Style.FILL - maskFilter = BlurMaskFilter(32f, BlurMaskFilter.Blur.OUTER) // Increased from 16f to 32f + maskFilter = BlurMaskFilter(32f, BlurMaskFilter.Blur.OUTER) } - // Pre-allocate paint objects to avoid GC private val tmpPaint = Paint() - // Calculate block size based on view dimensions and board size private var blockSize = 0f private var boardLeft = 0f private var boardTop = 0f - // Game loop handler and runnable private val handler = Handler(Looper.getMainLooper()) private val gameLoopRunnable = object : Runnable { override fun run() { @@ -127,7 +132,6 @@ class GameView @JvmOverloads constructor( } } - // Touch parameters private var lastTouchX = 0f private var lastTouchY = 0f private var startX = 0f @@ -178,6 +182,18 @@ class GameView @JvmOverloads constructor( private var pulseAlpha = 0f private var isPulsing = false private var linesToPulse = mutableListOf() // Track which lines are being cleared + private var gameOverAnimator: ValueAnimator? = null + private var gameOverAlpha = 0f + private var isGameOverAnimating = false + // Add new game over animation properties + private var gameOverBlocksY = mutableListOf() + private var gameOverBlocksX = mutableListOf() + private var gameOverBlocksRotation = mutableListOf() + private var gameOverBlocksSpeed = mutableListOf() + private var gameOverBlocksSize = mutableListOf() + private var gameOverColorTransition = 0f + private var gameOverShakeAmount = 0f + private var gameOverFinalAlpha = 0.8f private val ghostPaint = Paint().apply { style = Paint.Style.STROKE @@ -204,7 +220,7 @@ class GameView @JvmOverloads constructor( pause() // Load saved block skin - val prefs = context.getSharedPreferences("mintris_progression", Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences("pixelmintdrop_progression", Context.MODE_PRIVATE) currentBlockSkin = prefs.getString("selected_block_skin", "block_skin_1") ?: "block_skin_1" // Connect our callbacks to the GameBoard @@ -302,11 +318,29 @@ class GameView @JvmOverloads constructor( * Set the current block skin */ fun setBlockSkin(skinId: String) { - currentBlockSkin = skinId + Log.d("BlockSkin", "Setting block skin from: $currentBlockSkin to: $skinId") + + // Check if the skin exists in our map + if (!blockSkinPaints.containsKey(skinId)) { + Log.e("BlockSkin", "Warning: Unknown block skin: $skinId - available skins: ${blockSkinPaints.keys}") + // Fall back to default skin if the requested one doesn't exist + if (blockSkinPaints.containsKey("block_skin_1")) { + currentBlockSkin = "block_skin_1" + } + } else { + // Set the skin + currentBlockSkin = skinId + } + // Save the selection to SharedPreferences - val prefs = context.getSharedPreferences("mintris_progression", Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences("pixelmintdrop_progression", Context.MODE_PRIVATE) prefs.edit().putString("selected_block_skin", skinId).commit() + + // Force a refresh of the view invalidate() + + // Log confirmation + Log.d("BlockSkin", "Block skin is now: $currentBlockSkin") } /** @@ -318,6 +352,17 @@ class GameView @JvmOverloads constructor( * Start the game */ fun start() { + // Reset game over animation state + isGameOverAnimating = false + gameOverAlpha = 0f + gameOverBlocksY.clear() + gameOverBlocksX.clear() + gameOverBlocksRotation.clear() + gameOverBlocksSpeed.clear() + gameOverBlocksSize.clear() + gameOverColorTransition = 0f + gameOverShakeAmount = 0f + isPaused = false isRunning = true gameBoard.startGame() // Add this line to ensure a new piece is spawned @@ -340,6 +385,18 @@ class GameView @JvmOverloads constructor( fun reset() { isRunning = false isPaused = true + + // Reset game over animation state + isGameOverAnimating = false + gameOverAlpha = 0f + gameOverBlocksY.clear() + gameOverBlocksX.clear() + gameOverBlocksRotation.clear() + gameOverBlocksSpeed.clear() + gameOverBlocksSize.clear() + gameOverColorTransition = 0f + gameOverShakeAmount = 0f + gameBoard.reset() gameBoard.startGame() // Add this line to ensure a new piece is spawned handler.removeCallbacks(gameLoopRunnable) @@ -351,9 +408,17 @@ class GameView @JvmOverloads constructor( */ private fun update() { if (gameBoard.isGameOver) { - isRunning = false - isPaused = true - onGameOver?.invoke(gameBoard.score) + // Only trigger game over handling once when transitioning to game over state + if (isRunning) { + Log.d(TAG, "Game has ended - transitioning to game over state") + isRunning = false + isPaused = true + + // Always trigger animation for each game over + Log.d(TAG, "Triggering game over animation from update()") + startGameOverAnimation() + onGameOver?.invoke(gameBoard.score) + } return } @@ -367,77 +432,29 @@ class GameView @JvmOverloads constructor( override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) - // Force hardware acceleration - Critical for performance - setLayerType(LAYER_TYPE_HARDWARE, null) + // Calculate block size based on view dimensions and board size + blockSize = minOf( + w / (gameBoard.width + 2f), // Add padding + h / (gameBoard.height + 2f) // Add padding + ) - // Update gesture exclusion rect for edge-to-edge rendering - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setSystemGestureExclusionRects(listOf(Rect(0, 0, w, h))) - } - - calculateDimensions(w, h) - } - - /** - * Calculate dimensions for the board and blocks based on view size - */ - private fun calculateDimensions(width: Int, height: Int) { - // Calculate block size based on available space - val horizontalBlocks = gameBoard.width - val verticalBlocks = gameBoard.height - - // Account for all glow effects and borders - val borderPadding = 16f // Padding for border glow effects - - // Calculate block size to fit the height exactly, accounting for all padding - blockSize = (height.toFloat() - (borderPadding * 2)) / verticalBlocks - - // Calculate total board width - val totalBoardWidth = blockSize * horizontalBlocks - - // Center horizontally - boardLeft = (width - totalBoardWidth) / 2 - boardTop = borderPadding // Start with border padding from top - - // Calculate the total height needed for the board - val totalHeight = blockSize * verticalBlocks - - // Log dimensions for debugging - Log.d(TAG, "Board dimensions: width=$width, height=$height, blockSize=$blockSize, boardLeft=$boardLeft, boardTop=$boardTop, totalHeight=$totalHeight") + // Center the board + boardLeft = (w - gameBoard.width * blockSize) / 2f + boardTop = (h - gameBoard.height * blockSize) / 2f } override fun onDraw(canvas: Canvas) { - // Skip drawing if paused or game over - faster return - if (isPaused || gameBoard.isGameOver) { - super.onDraw(canvas) - return - } - - // Set hardware layer type during draw for better performance - val wasHardwareAccelerated = isHardwareAccelerated - if (!wasHardwareAccelerated) { - setLayerType(LAYER_TYPE_HARDWARE, null) - } - super.onDraw(canvas) - // Draw background (already black from theme) + // Draw the game board + drawBoard(canvas) - // Draw board border glow - drawBoardBorder(canvas) + // Draw ghost piece + drawGhostPiece(canvas) - // Draw grid (very subtle) - drawGrid(canvas) - - // Draw locked pieces - drawLockedBlocks(canvas) - - if (!gameBoard.isGameOver && isRunning) { - // Draw ghost piece (landing preview) - drawGhostPiece(canvas) - - // Draw active piece - drawActivePiece(canvas) + // Draw current piece + currentPiece?.let { piece -> + drawPiece(canvas, piece) } } @@ -595,219 +612,52 @@ class GameView @JvmOverloads constructor( * Draw the ghost piece (landing preview) */ private fun drawGhostPiece(canvas: Canvas) { - val piece = gameBoard.getCurrentPiece() ?: return - val ghostY = gameBoard.getGhostY() - - // Draw semi-transparent background for each block - 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 = ghostY + y - - if (boardX >= 0 && boardX < gameBoard.width) { - val screenX = boardLeft + boardX * blockSize - val screenY = boardTop + boardY * blockSize - - // Draw background - canvas.drawRect( - screenX + 1f, - screenY + 1f, - screenX + blockSize - 1f, - screenY + blockSize - 1f, - ghostBackgroundPaint - ) - - // Draw border - canvas.drawRect( - screenX + 1f, - screenY + 1f, - screenX + blockSize - 1f, - screenY + blockSize - 1f, - ghostBorderPaint - ) - - // Draw outline - canvas.drawRect( - screenX + 1f, - screenY + 1f, - screenX + blockSize - 1f, - screenY + blockSize - 1f, - ghostPaint - ) - } - } - } + currentPiece?.let { piece -> + val ghostY = calculateGhostY(piece) + val originalY = piece.y + piece.y = ghostY + + ghostBlockPaint.alpha = 50 + drawPiece(canvas, piece, alpha = 50) + + piece.y = originalY } } - /** - * Draw a single tetris block at the given grid position - */ - private fun drawBlock(canvas: Canvas, x: Int, y: Int, isGhost: Boolean, isPulsingLine: Boolean) { - val left = boardLeft + x * blockSize - val top = boardTop + y * blockSize - val right = left + blockSize - val bottom = top + blockSize + private fun calculateGhostY(piece: GamePiece): Int { + var testY = piece.y + while (testY < gameBoard.height && !gameBoard.wouldCollide(piece, piece.x, testY + 1)) { + testY++ + } + return testY + } + + private fun drawPiece(canvas: Canvas, piece: GamePiece, offsetX: Float = 0f, offsetY: Float = 0f, alpha: Int = 255) { + val width = piece.getWidth() + val height = piece.getHeight() - // Save canvas state before drawing block effects - canvas.save() - - // Get the current block skin paint - val paint = blockSkinPaints[currentBlockSkin] ?: blockSkinPaints["block_skin_1"]!! - - // Create a clone of the paint to avoid modifying the original - val blockPaint = Paint(paint) - - // Draw block based on current skin - when (currentBlockSkin) { - "block_skin_1" -> { // Classic - // Draw outer glow - blockGlowPaint.color = if (isGhost) Color.argb(30, 255, 255, 255) else Color.WHITE - canvas.drawRect(left - 2f, top - 2f, right + 2f, bottom + 2f, blockGlowPaint) - - // Draw block - blockPaint.color = if (isGhost) Color.argb(30, 255, 255, 255) else Color.WHITE - blockPaint.alpha = if (isGhost) 30 else 255 - canvas.drawRect(left, top, right, bottom, blockPaint) - - // Draw inner glow - glowPaint.color = if (isGhost) Color.argb(30, 255, 255, 255) else Color.WHITE - canvas.drawRect(left + 1f, top + 1f, right - 1f, bottom - 1f, glowPaint) - } - "block_skin_2" -> { // Neon - // Stronger outer glow for neon skin - blockGlowPaint.color = if (isGhost) Color.argb(30, 255, 0, 255) else Color.parseColor("#FF00FF") - blockGlowPaint.maskFilter = BlurMaskFilter(16f, BlurMaskFilter.Blur.OUTER) - canvas.drawRect(left - 4f, top - 4f, right + 4f, bottom + 4f, blockGlowPaint) - - // For neon, use semi-translucent fill with strong glowing edges - blockPaint.style = Paint.Style.FILL_AND_STROKE - blockPaint.strokeWidth = 2f - blockPaint.maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL) - - if (isGhost) { - blockPaint.color = Color.argb(30, 255, 0, 255) - blockPaint.alpha = 30 - } else { - blockPaint.color = Color.parseColor("#66004D") // Darker magenta fill - blockPaint.alpha = 170 // More opaque to be more visible - } - - // Draw block with neon effect - canvas.drawRect(left, top, right, bottom, blockPaint) - - // Draw a brighter border for better visibility - val borderPaint = Paint().apply { - color = Color.parseColor("#FF00FF") - style = Paint.Style.STROKE - strokeWidth = 3f - alpha = 255 - isAntiAlias = true - maskFilter = BlurMaskFilter(6f, BlurMaskFilter.Blur.NORMAL) - } - canvas.drawRect(left, top, right, bottom, borderPaint) - - // Inner glow for neon blocks - glowPaint.color = if (isGhost) Color.argb(10, 255, 0, 255) else Color.parseColor("#FF00FF") - glowPaint.alpha = if (isGhost) 10 else 100 - glowPaint.style = Paint.Style.STROKE - glowPaint.strokeWidth = 2f - glowPaint.maskFilter = BlurMaskFilter(4f, BlurMaskFilter.Blur.NORMAL) - canvas.drawRect(left + 4f, top + 4f, right - 4f, bottom - 4f, glowPaint) - } - "block_skin_3" -> { // Retro - // Draw pixelated block with retro effect - blockPaint.color = if (isGhost) Color.argb(30, 255, 90, 95) else Color.parseColor("#FF5A5F") - blockPaint.alpha = if (isGhost) 30 else 255 - - // Draw main block - canvas.drawRect(left, top, right, bottom, blockPaint) - - // Draw pixelated highlights - val highlightPaint = Paint().apply { - color = Color.parseColor("#FF8A8F") - isAntiAlias = false - style = Paint.Style.FILL - } - // Top and left highlights - canvas.drawRect(left, top, right - 2f, top + 2f, highlightPaint) - canvas.drawRect(left, top, left + 2f, bottom - 2f, highlightPaint) - - // Draw pixelated shadows - val shadowPaint = Paint().apply { - color = Color.parseColor("#CC4A4F") - isAntiAlias = false - style = Paint.Style.FILL - } - // Bottom and right shadows - canvas.drawRect(left + 2f, bottom - 2f, right, bottom, shadowPaint) - canvas.drawRect(right - 2f, top + 2f, right, bottom - 2f, shadowPaint) - } - "block_skin_4" -> { // Minimalist - // Draw clean, simple block with subtle border - blockPaint.color = if (isGhost) Color.argb(30, 0, 0, 0) else Color.BLACK - blockPaint.alpha = if (isGhost) 30 else 255 - blockPaint.style = Paint.Style.FILL - canvas.drawRect(left, top, right, bottom, blockPaint) - - // Draw subtle border - val borderPaint = Paint().apply { - color = Color.parseColor("#333333") - style = Paint.Style.STROKE - strokeWidth = 1f - isAntiAlias = true - } - canvas.drawRect(left, top, right, bottom, borderPaint) - } - "block_skin_5" -> { // Galaxy - // Draw cosmic glow effect - blockGlowPaint.color = if (isGhost) Color.argb(30, 102, 252, 241) else Color.parseColor("#66FCF1") - blockGlowPaint.maskFilter = BlurMaskFilter(20f, BlurMaskFilter.Blur.OUTER) - canvas.drawRect(left - 8f, top - 8f, right + 8f, bottom + 8f, blockGlowPaint) - - // Draw main block with gradient - val gradient = LinearGradient( - left, top, right, bottom, - Color.parseColor("#66FCF1"), - Color.parseColor("#45B7AF"), - Shader.TileMode.CLAMP - ) - blockPaint.shader = gradient - blockPaint.color = if (isGhost) Color.argb(30, 102, 252, 241) else Color.parseColor("#66FCF1") - blockPaint.alpha = if (isGhost) 30 else 255 - blockPaint.style = Paint.Style.FILL - canvas.drawRect(left, top, right, bottom, blockPaint) - - // Draw star-like sparkles - if (!isGhost) { - val sparklePaint = Paint().apply { - color = Color.WHITE - style = Paint.Style.FILL - isAntiAlias = true - maskFilter = BlurMaskFilter(4f, BlurMaskFilter.Blur.NORMAL) - } - // Add small white dots for sparkle effect - canvas.drawCircle(left + 4f, top + 4f, 1f, sparklePaint) - canvas.drawCircle(right - 4f, bottom - 4f, 1f, sparklePaint) + blockPaint.alpha = alpha + for (y in 0 until height) { + for (x in 0 until width) { + if (piece.isBlockAt(x, y)) { + val left = offsetX + (piece.x + x) * blockSize + val top = offsetY + (piece.y + y) * blockSize + val right = left + blockSize + val bottom = top + blockSize + + // Draw block with subtle glow + blockGlowPaint.alpha = alpha / 4 + canvas.drawRect(left, top, right, bottom, blockGlowPaint) + + // Draw the main block + canvas.drawRect(left + 1f, top + 1f, right - 1f, bottom - 1f, blockPaint) + + // Draw border glow + glowPaint.alpha = alpha / 2 + canvas.drawRect(left, top, right, bottom, glowPaint) } } } - - // Draw pulse effect if animation is active and this is a pulsing line - if (isPulsing && isPulsingLine) { - val pulseBlockPaint = Paint().apply { - color = Color.WHITE - alpha = (255 * pulseAlpha).toInt() - isAntiAlias = true - style = Paint.Style.FILL - maskFilter = BlurMaskFilter(40f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) - } - canvas.drawRect(left - 16f, top - 16f, right + 16f, bottom + 16f, pulseBlockPaint) - } - - // Restore canvas state after drawing block effects - canvas.restore() } /** @@ -845,16 +695,7 @@ class GameView @JvmOverloads constructor( // Custom touch event handling override fun onTouchEvent(event: MotionEvent): Boolean { - if (!isRunning || isPaused || gameBoard.isGameOver) { - return true - } - - // Ignore touch events during the freeze period after a piece locks - val currentTime = System.currentTimeMillis() - if (currentTime < touchFreezeUntil) { - Log.d(TAG, "Ignoring touch event - freeze active for ${touchFreezeUntil - currentTime}ms more") - return true - } + if (!isRunning || isPaused) return false when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -862,124 +703,53 @@ class GameView @JvmOverloads constructor( startY = event.y lastTouchX = event.x lastTouchY = event.y - lastTapTime = currentTime // Set the tap time when touch starts - - // Reset direction lock - lockedDirection = null + lastTapTime = System.currentTimeMillis() + return true } MotionEvent.ACTION_MOVE -> { - val deltaX = event.x - lastTouchX - val deltaY = event.y - lastTouchY + val dx = event.x - lastTouchX + val dy = event.y - lastTouchY - // Check if we should lock direction - if (lockedDirection == null) { - val absDeltaX = abs(deltaX) - val absDeltaY = abs(deltaY) - - if (absDeltaX > blockSize * directionLockThreshold || - absDeltaY > blockSize * directionLockThreshold) { - // Lock to the dominant direction - lockedDirection = if (absDeltaX > absDeltaY) { - Direction.HORIZONTAL - } else { - Direction.VERTICAL - } + // Horizontal movement + if (abs(dx) > blockSize / 2) { + if (dx > 0) { + movePieceRight() + } else { + movePieceLeft() } + lastTouchX = event.x } - // Handle movement based on locked direction - when (lockedDirection) { - Direction.HORIZONTAL -> { - if (abs(deltaX) > blockSize * minMovementThreshold) { - if (deltaX > 0) { - gameBoard.moveRight() - } else { - gameBoard.moveLeft() - } - lastTouchX = event.x - if (currentTime - lastMoveTime >= moveCooldown) { - gameHaptics?.vibrateForPieceMove() - lastMoveTime = currentTime - } - invalidate() - } - } - Direction.VERTICAL -> { - if (deltaY > blockSize * minMovementThreshold) { - gameBoard.softDrop() - lastTouchY = event.y - if (currentTime - lastMoveTime >= moveCooldown) { - gameHaptics?.vibrateForPieceMove() - lastMoveTime = currentTime - } - invalidate() - } - } - null -> { - // No direction lock yet, don't process movement - } + // Vertical movement (soft drop) + if (dy > blockSize / 2) { + dropPiece() + lastTouchY = event.y } + + return true } MotionEvent.ACTION_UP -> { - val deltaX = event.x - startX - val deltaY = event.y - startY - val moveTime = currentTime - lastTapTime + val dx = event.x - startX + val dy = event.y - startY + val tapTime = System.currentTimeMillis() - lastTapTime - // Check for hold gesture (swipe up) - if (deltaY < -blockSize * minHoldDistance && - abs(deltaX) / abs(deltaY) < 0.5f) { - if (currentTime - lastHoldTime < holdCooldown) { - Log.d(TAG, "Hold blocked by cooldown - time since last: ${currentTime - lastHoldTime}ms, cooldown: ${holdCooldown}ms") - } else { - // Process the hold - Log.d(TAG, "Hold detected - deltaY: $deltaY, ratio: ${abs(deltaX) / abs(deltaY)}") - gameBoard.holdPiece() - lastHoldTime = currentTime - gameHaptics?.vibrateForPieceMove() - invalidate() - } - } - // Check for hard drop (must be faster and longer than soft drop) - else if (deltaY > blockSize * minHardDropDistance && - abs(deltaX) / abs(deltaY) < 0.5f && - (deltaY / moveTime) * 1000 > minSwipeVelocity) { - if (currentTime - lastHardDropTime < hardDropCooldown) { - Log.d(TAG, "Hard drop blocked by cooldown - time since last: ${currentTime - lastHardDropTime}ms, cooldown: ${hardDropCooldown}ms") - } else { - // Process the hard drop - Log.d(TAG, "Hard drop detected - deltaY: $deltaY, velocity: ${(deltaY / moveTime) * 1000}, ratio: ${abs(deltaX) / abs(deltaY)}") - gameBoard.hardDrop() - lastHardDropTime = currentTime - invalidate() - } - } - // Check for soft drop (slower and shorter than hard drop) - else if (deltaY > blockSize * minMovementThreshold && - deltaY < blockSize * maxSoftDropDistance && - (deltaY / moveTime) * 1000 < minSwipeVelocity) { - gameBoard.softDrop() - invalidate() - } - // Check for rotation (quick tap with minimal movement) - else if (moveTime < minTapTime * 1.5 && // Increased from 1.0 to 1.5 for more lenient timing - abs(deltaY) < maxTapMovement * 1.5 && // Increased from 1.0 to 1.5 for more lenient movement - abs(deltaX) < maxTapMovement * 1.5) { // Increased from 1.0 to 1.5 for more lenient movement - if (currentTime - lastRotationTime >= rotationCooldown) { - Log.d(TAG, "Rotation detected - moveTime: $moveTime, deltaX: $deltaX, deltaY: $deltaY") - gameBoard.rotate() - lastRotationTime = currentTime - invalidate() - } + // Detect tap for rotation + if (abs(dx) < blockSize / 2 && abs(dy) < blockSize / 2 && tapTime < 200) { + rotatePiece() } - // Reset direction lock - lockedDirection = null + // Detect swipe down for hard drop + if (dy > height / 4) { + hardDrop() + } + + return true } } - return true + return false } /** @@ -1005,7 +775,7 @@ class GameView @JvmOverloads constructor( /** * Get the next piece that will be spawned */ - fun getNextPiece(): Tetromino? { + fun getNextPiece(): GamePiece? { return gameBoard.getNextPiece() } @@ -1014,12 +784,46 @@ class GameView @JvmOverloads constructor( */ fun getGameBoard(): GameBoard = gameBoard + /** + * Get the current piece type (for accessibility) + */ + fun getCurrentPieceType(): TetrominoType? { + return gameBoard.getCurrentPiece()?.type + } + /** * Clean up resources when view is detached */ override fun onDetachedFromWindow() { super.onDetachedFromWindow() handler.removeCallbacks(gameLoopRunnable) + releaseResources() + } + + /** + * Release resources to prevent memory leaks + */ + fun releaseResources() { + // Stop all animations + pulseAnimator?.cancel() + pulseAnimator?.removeAllUpdateListeners() + pulseAnimator = null + + // Stop game over animation + gameOverAnimator?.cancel() + gameOverAnimator?.removeAllUpdateListeners() + gameOverAnimator = null + + // Remove callbacks + handler.removeCallbacksAndMessages(null) + + // Clean up references + gameHaptics = null + onGameOver = null + onGameStateChanged = null + onPieceMove = null + onPieceLock = null + onLineClear = null } /** @@ -1133,4 +937,301 @@ class GameView @JvmOverloads constructor( super.setBackgroundColor(color) invalidate() } + + /** + * Start the game over animation + */ + fun startGameOverAnimation() { + Log.d(TAG, "Starting game over animation") + + // Check if game over already showing + if (isGameOverAnimating && gameOverAlpha > 0.5f) { + Log.d(TAG, "Game over animation already active - skipping") + return + } + + // Cancel any existing animations + pulseAnimator?.cancel() + gameOverAnimator?.cancel() + + // Trigger haptic feedback + gameHaptics?.vibrateForGameOver() + + // Force immediate visual feedback + isGameOverAnimating = true + gameOverAlpha = 0.3f + invalidate() + + // Generate falling blocks based on current board state + generateGameOverBlocks() + + // Create new game over animation + gameOverAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 3000L // Increased from 2000L (2 seconds) to 3000L (3 seconds) + interpolator = LinearInterpolator() + + addUpdateListener { animation -> + val progress = animation.animatedValue as Float + + // Main alpha transition for overlay + gameOverAlpha = when { + progress < 0.2f -> progress * 5f // Quick fade in (first 20% of animation) + else -> 1f // Hold at full opacity + } + + // Color transition effect (start after 40% of animation) + gameOverColorTransition = when { + progress < 0.4f -> 0f + progress < 0.8f -> (progress - 0.4f) * 2.5f // Transition during 40%-80% + else -> 1f + } + + // Screen shake effect (strongest at beginning, fades out) + gameOverShakeAmount = when { + progress < 0.3f -> progress * 3.33f // Ramp up + progress < 0.6f -> 1f - (progress - 0.3f) * 3.33f // Ramp down + else -> 0f // No shake + } + + // Update falling blocks + updateGameOverBlocks() + + isGameOverAnimating = true + invalidate() + Log.d(TAG, "Game over animation update: alpha = $gameOverAlpha, progress = $progress") + } + + addListener(object : android.animation.AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: android.animation.Animator) { + Log.d(TAG, "Game over animation ended - Final alpha: $gameOverFinalAlpha") + isGameOverAnimating = true // Keep true to maintain visibility + gameOverAlpha = gameOverFinalAlpha // Keep at 80% opacity + invalidate() + } + }) + } + gameOverAnimator?.start() + } + + /** + * Generate falling blocks for the game over animation based on current board state + */ + private fun generateGameOverBlocks() { + // Clear existing blocks + gameOverBlocksY.clear() + gameOverBlocksX.clear() + gameOverBlocksRotation.clear() + gameOverBlocksSpeed.clear() + gameOverBlocksSize.clear() + + // Generate 30-40 blocks across the board + val numBlocks = (30 + Math.random() * 10).toInt() + + for (i in 0 until numBlocks) { + // Start positions - distribute across the board width but clustered near the top + gameOverBlocksX.add(boardLeft + (Math.random() * gameBoard.width * blockSize).toFloat()) + gameOverBlocksY.add((boardTop - blockSize * 2 + Math.random() * height * 0.3f).toFloat()) + + // Random rotation + gameOverBlocksRotation.add((Math.random() * 360).toFloat()) + + // Random fall speed (some faster, some slower) + gameOverBlocksSpeed.add((5 + Math.random() * 15).toFloat()) + + // Slightly varied block sizes + gameOverBlocksSize.add((0.8f + Math.random() * 0.4f).toFloat()) + } + } + + /** + * Update the position of falling blocks in the game over animation + */ + private fun updateGameOverBlocks() { + for (i in gameOverBlocksY.indices) { + // Update Y position based on speed + gameOverBlocksY[i] += gameOverBlocksSpeed[i] + + // Update rotation + gameOverBlocksRotation[i] += gameOverBlocksSpeed[i] * 0.5f + + // Accelerate falling + gameOverBlocksSpeed[i] *= 1.03f + } + } + + /** + * Check if the game is active (running, not paused, not game over) + */ + fun isActive(): Boolean { + return isRunning && !isPaused && !gameBoard.isGameOver + } + + /** + * Move the current piece left (for gamepad/keyboard support) + */ + fun moveLeft() { + if (!isActive()) return + gameBoard.moveLeft() + gameHaptics?.vibrateForPieceMove() + invalidate() + } + + /** + * Move the current piece right (for gamepad/keyboard support) + */ + fun moveRight() { + if (!isActive()) return + gameBoard.moveRight() + gameHaptics?.vibrateForPieceMove() + invalidate() + } + + /** + * Rotate the current piece (for gamepad/keyboard support) + */ + fun rotate() { + if (!isActive()) return + gameBoard.rotate() + gameHaptics?.vibrateForPieceMove() + invalidate() + } + + /** + * Rotate the current piece counterclockwise (for gamepad/keyboard support) + */ + fun rotateCounterClockwise() { + if (!isActive()) return + gameBoard.rotateCounterClockwise() + gameHaptics?.vibrateForPieceMove() + invalidate() + } + + /** + * Perform a soft drop (move down faster) (for gamepad/keyboard support) + */ + fun softDrop() { + if (!isActive()) return + gameBoard.softDrop() + gameHaptics?.vibrateForPieceMove() + invalidate() + } + + /** + * Perform a hard drop (instant drop) (for gamepad/keyboard support) + */ + fun hardDrop() { + if (!isActive()) return + gameBoard.hardDrop() + // Hard drop haptic feedback is handled by the game board via onPieceLock + invalidate() + } + + /** + * Hold the current piece (for gamepad/keyboard support) + */ + fun holdPiece() { + if (!isActive()) return + gameBoard.holdPiece() + gameHaptics?.vibrateForPieceMove() + invalidate() + } + + fun getNextPiece(): GamePiece? = nextPiece + + fun getCurrentPiece(): GamePiece? = currentPiece + + fun spawnNewPiece() { + currentPiece = gameBoard.spawnNewPiece() + nextPiece = gameBoard.getNextPiece() + onNextPieceChanged?.invoke() + } + + fun rotatePiece() { + currentPiece?.let { piece -> + if (gameBoard.rotatePiece(piece)) { + invalidate() + } + } + } + + fun movePieceLeft() { + currentPiece?.let { piece -> + if (gameBoard.movePiece(piece, -1, 0)) { + invalidate() + } + } + } + + fun movePieceRight() { + currentPiece?.let { piece -> + if (gameBoard.movePiece(piece, 1, 0)) { + invalidate() + } + } + } + + fun dropPiece() { + currentPiece?.let { piece -> + if (gameBoard.movePiece(piece, 0, 1)) { + invalidate() + } + } + } + + fun hardDrop() { + currentPiece?.let { piece -> + val ghostY = calculateGhostY(piece) + if (ghostY > piece.y) { + piece.y = ghostY + gameBoard.lockPiece(piece) + spawnNewPiece() + invalidate() + } + } + } + + fun startGame() { + if (!isRunning) { + isRunning = true + isPaused = false + gameBoard.reset() + spawnNewPiece() + handler.post(gameLoopRunnable) + } + } + + fun pauseGame() { + if (isRunning && !isPaused) { + isPaused = true + handler.removeCallbacks(gameLoopRunnable) + } + } + + fun resumeGame() { + if (isRunning && isPaused) { + isPaused = false + handler.post(gameLoopRunnable) + } + } + + fun stopGame() { + isRunning = false + isPaused = false + handler.removeCallbacks(gameLoopRunnable) + } + + fun resetGame() { + stopGame() + gameBoard.reset() + currentPiece = null + nextPiece = null + score = 0 + invalidate() + } + + fun getScore(): Int = score + + fun setGameHaptics(haptics: GameHaptics?) { + gameHaptics = haptics + } } diff --git a/app/src/main/java/com/pixelmintdrop/game/GamepadController.kt b/app/src/main/java/com/pixelmintdrop/game/GamepadController.kt new file mode 100644 index 0000000..83718b2 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/game/GamepadController.kt @@ -0,0 +1,510 @@ +package com.pixelmintdrop.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.os.Handler +import android.os.Looper + +/** + * GamepadController handles gamepad input for the pixelmintdrop 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 { + val gamepads = mutableListOf() + 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 { + 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 quad (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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/HoldPieceView.kt b/app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt similarity index 97% rename from app/src/main/java/com/mintris/game/HoldPieceView.kt rename to app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt index 6f51022..ae7272c 100644 --- a/app/src/main/java/com/mintris/game/HoldPieceView.kt +++ b/app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt @@ -1,4 +1,4 @@ -package com.mintris.game +package com.pixelmintdrop.game import android.content.Context import android.graphics.BlurMaskFilter @@ -7,8 +7,7 @@ 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 com.pixelmintdrop.model.GameBoard import kotlin.math.min /** diff --git a/app/src/main/java/com/pixelmintdrop/game/NextPieceView.kt b/app/src/main/java/com/pixelmintdrop/game/NextPieceView.kt new file mode 100644 index 0000000..f6d3543 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/game/NextPieceView.kt @@ -0,0 +1,97 @@ +package com.pixelmintdrop.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 game 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?.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.toFloat() + 2f), + canvas.height.toFloat() / (height.toFloat() + 2f) + ) + + // Center the piece in the preview area + val previewLeft = (canvas.width.toFloat() - width.toFloat() * previewBlockSize) / 2f + val previewTop = (canvas.height.toFloat() - height.toFloat() * previewBlockSize) / 2f + + // 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.toFloat() * previewBlockSize + previewBlockSize, + previewTop + height.toFloat() * previewBlockSize + previewBlockSize, + glowPaint + ) + + for (y in 0 until height) { + for (x in 0 until width) { + if (piece.isBlockAt(x, y)) { + val left = previewLeft + x.toFloat() * previewBlockSize + val top = previewTop + y.toFloat() * previewBlockSize + val right = left + previewBlockSize + val bottom = top + previewBlockSize + + // Draw block with subtle glow + val rect = RectF(left + 1f, top + 1f, right - 1f, bottom - 1f) + canvas.drawRect(rect, blockPaint) + + // Draw subtle border glow + val glowRect = RectF(left, top, right, bottom) + canvas.drawRect(glowRect, glowPaint) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/TitleScreen.kt b/app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt similarity index 76% rename from app/src/main/java/com/mintris/game/TitleScreen.kt rename to app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt index 34627f7..f9baa6d 100644 --- a/app/src/main/java/com/mintris/game/TitleScreen.kt +++ b/app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt @@ -1,4 +1,4 @@ -package com.mintris.game +package com.pixelmintdrop.game import android.content.Context import android.graphics.Canvas @@ -10,9 +10,9 @@ 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 com.pixelmintdrop.model.HighScoreManager +import com.pixelmintdrop.model.HighScore +import com.pixelmintdrop.model.PlayerProgressionManager import kotlin.math.abs import androidx.core.graphics.withTranslation import androidx.core.graphics.withScale @@ -33,7 +33,7 @@ class TitleScreen @JvmOverloads constructor( private val random = Random() private var width = 0 private var height = 0 - private val tetrominosToAdd = mutableListOf() + private val piecesToAdd = mutableListOf() private val highScoreManager = HighScoreManager(context) // Pre-allocate HighScoreManager // Touch handling variables @@ -50,8 +50,8 @@ class TitleScreen @JvmOverloads constructor( private var themeColor = Color.WHITE private var backgroundColor = Color.BLACK - // Define tetromino shapes (I, O, T, S, Z, J, L) - private val tetrominoShapes = arrayOf( + // Define piece shapes (I, O, T, S, Z, J, L) + private val pieceShapes = arrayOf( // I arrayOf( intArrayOf(0, 0, 0, 0), @@ -96,8 +96,8 @@ class TitleScreen @JvmOverloads constructor( ) ) - // Tetromino class to represent falling pieces - private class Tetromino( + // FallingPiece class to represent falling pieces + private class FallingPiece( var x: Float, var y: Float, val shape: Array, @@ -106,7 +106,7 @@ class TitleScreen @JvmOverloads constructor( val rotation: Int = 0 ) - private val tetrominos = mutableListOf() + private val pieces = mutableListOf() init { // Title text settings @@ -138,14 +138,14 @@ class TitleScreen @JvmOverloads constructor( alpha = 200 } - // General paint settings for tetrominos + // General paint settings for pieces paint.apply { color = themeColor style = Paint.Style.FILL isAntiAlias = true } - // Glow paint settings for tetrominos + // Glow paint settings for pieces glowPaint.apply { color = themeColor style = Paint.Style.FILL @@ -159,26 +159,26 @@ class TitleScreen @JvmOverloads constructor( width = w height = h - // Clear existing tetrominos - tetrominos.clear() + // Clear existing pieces + pieces.clear() - // Initialize some tetrominos + // Initialize some pieces repeat(20) { - val tetromino = createRandomTetromino() - tetrominos.add(tetromino) + val piece = createRandomPiece() + pieces.add(piece) } } - private fun createRandomTetromino(): Tetromino { + private fun createRandomPiece(): FallingPiece { 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 shapeIndex = random.nextInt(pieceShapes.size) + val shape = pieceShapes[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) + return FallingPiece(x, y, shape, speed, scale, rotation) } override fun onDraw(canvas: Canvas) { @@ -188,37 +188,37 @@ class TitleScreen @JvmOverloads constructor( // Draw background using the current background color canvas.drawColor(backgroundColor) - // Add any pending tetrominos - tetrominos.addAll(tetrominosToAdd) - tetrominosToAdd.clear() + // Add any pending pieces + pieces.addAll(piecesToAdd) + piecesToAdd.clear() - // Update and draw falling tetrominos - val tetrominosToRemove = mutableListOf() + // Update and draw falling pieces + val piecesToRemove = mutableListOf() - for (tetromino in tetrominos) { - tetromino.y += tetromino.speed + for (piece in pieces) { + piece.y += piece.speed - // Remove tetrominos that have fallen off the screen - if (tetromino.y > height) { - tetrominosToRemove.add(tetromino) - tetrominosToAdd.add(createRandomTetromino()) + // Remove pieces that have fallen off the screen + if (piece.y > height) { + piecesToRemove.add(piece) + piecesToAdd.add(createRandomPiece()) } 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) { + // Draw the piece + for (y in 0 until piece.shape.size) { + for (x in 0 until piece.shape.size) { + if (piece.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) { + canvas.withTranslation(piece.x, piece.y) { + withScale(piece.scale, piece.scale) { + withRotation(piece.rotation.toFloat(), + piece.shape.size * cellSize / 2, + piece.shape.size * cellSize / 2) { // Draw glow canvas.drawRect(left - 8f, top - 8f, right + 8f, bottom + 8f, glowPaint) // Draw block @@ -230,21 +230,22 @@ class TitleScreen @JvmOverloads constructor( } } } catch (e: Exception) { - Log.e("TitleScreen", "Error drawing tetromino", e) + Log.e("TitleScreen", "Error drawing piece", e) } } } - // Remove tetrominos that fell off the screen - tetrominos.removeAll(tetrominosToRemove) + // Remove pieces that fell off the screen + pieces.removeAll(piecesToRemove) // Draw title val titleY = height * 0.4f - canvas.drawText("mintris", width / 2f, titleY, titlePaint) + canvas.drawText("Pixel Mint Drop", width / 2f, titleY, titlePaint) // Draw high scores using pre-allocated manager val highScores: List = 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") @@ -252,6 +253,7 @@ class TitleScreen @JvmOverloads constructor( 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 @@ -260,8 +262,15 @@ class TitleScreen @JvmOverloads constructor( } } - // Draw "touch to start" prompt - canvas.drawText("touch to start", width / 2f, height * 0.7f, promptPaint) + // 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() @@ -283,10 +292,10 @@ class TitleScreen @JvmOverloads constructor( 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 + // Update piece positions + for (piece in pieces) { + piece.x += deltaX * 0.5f + piece.y += deltaY * 0.5f } lastTouchX = event.x diff --git a/app/src/main/java/com/pixelmintdrop/model/GameBoard.kt b/app/src/main/java/com/pixelmintdrop/model/GameBoard.kt new file mode 100644 index 0000000..35d7d06 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/model/GameBoard.kt @@ -0,0 +1,199 @@ +package com.pixelmintdrop.model + +import android.util.Log + +/** + * Represents the game board and manages piece placement and collision detection + */ +class GameBoard { + companion object { + const val WIDTH = 10 + const val HEIGHT = 20 + const val INITIAL_DROP_INTERVAL = 1000L // 1 second + const val MIN_DROP_INTERVAL = 100L // 0.1 seconds + } + + // Board state + private val board = Array(HEIGHT) { Array(WIDTH) { false } } + private var currentPiece: GamePiece? = null + private var nextPiece: GamePiece? = null + + // Game state + var dropInterval = INITIAL_DROP_INTERVAL + private set + + val width: Int = WIDTH + val height: Int = HEIGHT + + fun reset() { + // Clear the board + for (y in 0 until HEIGHT) { + for (x in 0 until WIDTH) { + board[y][x] = false + } + } + + // Reset pieces + currentPiece = null + nextPiece = null + + // Reset drop interval + dropInterval = INITIAL_DROP_INTERVAL + } + + fun spawnNewPiece(): GamePiece { + // Get the next piece or create a new one if none exists + currentPiece = nextPiece ?: createRandomPiece() + nextPiece = createRandomPiece() + + // Position the piece at the top center of the board + currentPiece?.let { piece -> + piece.x = (WIDTH - piece.getWidth()) / 2 + piece.y = 0 + } + + return currentPiece!! + } + + fun getNextPiece(): GamePiece? = nextPiece + + fun getCurrentPiece(): GamePiece? = currentPiece + + fun hasBlockAt(x: Int, y: Int): Boolean { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return false + return board[y][x] + } + + fun wouldCollide(piece: GamePiece, newX: Int, newY: Int): Boolean { + val width = piece.getWidth() + val height = piece.getHeight() + + for (y in 0 until height) { + for (x in 0 until width) { + if (piece.isBlockAt(x, y)) { + val boardX = newX + x + val boardY = newY + y + + // Check board boundaries + if (boardX < 0 || boardX >= WIDTH || boardY >= HEIGHT) { + return true + } + + // Check collision with placed blocks + if (boardY >= 0 && board[boardY][boardX]) { + return true + } + } + } + } + + return false + } + + fun movePiece(piece: GamePiece, dx: Int, dy: Int): Boolean { + val newX = piece.x + dx + val newY = piece.y + dy + + if (!wouldCollide(piece, newX, newY)) { + piece.x = newX + piece.y = newY + return true + } + + return false + } + + fun rotatePiece(piece: GamePiece): Boolean { + // Save current state + val originalRotation = piece.rotation + + // Try to rotate + piece.rotate() + + // Check if new position is valid + if (wouldCollide(piece, piece.x, piece.y)) { + // If not valid, try wall kicks + val kicks = arrayOf( + Pair(1, 0), // Try moving right + Pair(-1, 0), // Try moving left + Pair(0, -1), // Try moving up + Pair(2, 0), // Try moving right 2 + Pair(-2, 0) // Try moving left 2 + ) + + for ((kickX, kickY) in kicks) { + if (!wouldCollide(piece, piece.x + kickX, piece.y + kickY)) { + piece.x += kickX + piece.y += kickY + return true + } + } + + // If no wall kick worked, revert rotation + piece.rotation = originalRotation + return false + } + + return true + } + + fun lockPiece(piece: GamePiece) { + val width = piece.getWidth() + val height = piece.getHeight() + + // Place the piece on the board + for (y in 0 until height) { + for (x in 0 until width) { + if (piece.isBlockAt(x, y)) { + val boardX = piece.x + x + val boardY = piece.y + y + if (boardY >= 0 && boardY < HEIGHT && boardX >= 0 && boardX < WIDTH) { + board[boardY][boardX] = true + } + } + } + } + + // Clear any completed lines + clearLines() + } + + private fun clearLines() { + var linesCleared = 0 + var y = HEIGHT - 1 + + while (y >= 0) { + if (isLineFull(y)) { + // Move all lines above down + for (moveY in y downTo 1) { + for (x in 0 until WIDTH) { + board[moveY][x] = board[moveY - 1][x] + } + } + // Clear top line + for (x in 0 until WIDTH) { + board[0][x] = false + } + linesCleared++ + } else { + y-- + } + } + + // Increase speed based on lines cleared + if (linesCleared > 0) { + dropInterval = (dropInterval * 0.95).toLong().coerceAtLeast(MIN_DROP_INTERVAL) + } + } + + private fun isLineFull(y: Int): Boolean { + for (x in 0 until WIDTH) { + if (!board[y][x]) return false + } + return true + } + + private fun createRandomPiece(): GamePiece { + return GamePiece(GamePieceType.values().random()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/model/GamePiece.kt b/app/src/main/java/com/pixelmintdrop/model/GamePiece.kt new file mode 100644 index 0000000..37e0268 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/model/GamePiece.kt @@ -0,0 +1,48 @@ +package com.pixelmintdrop.model + +/** + * Represents a game piece with its type, position, and rotation + */ +class GamePiece(val type: GamePieceType) { + var x: Int = 0 + var y: Int = 0 + var rotation: Int = 0 + private set + + private val blocks: Array> = type.getBlocks() + + fun getWidth(): Int = blocks[0].size + + fun getHeight(): Int = blocks.size + + fun isBlockAt(x: Int, y: Int): Boolean { + if (x < 0 || x >= getWidth() || y < 0 || y >= getHeight()) return false + return blocks[y][x] + } + + fun rotate() { + rotation = (rotation + 1) % 4 + rotateBlocks() + } + + private fun rotateBlocks() { + val width = getWidth() + val height = getHeight() + val rotated = Array(width) { Array(height) { false } } + + for (y in 0 until height) { + for (x in 0 until width) { + when (rotation) { + 1 -> rotated[x][height - 1 - y] = blocks[y][x] + 2 -> rotated[height - 1 - y][width - 1 - x] = blocks[y][x] + 3 -> rotated[width - 1 - x][y] = blocks[y][x] + else -> rotated[y][x] = blocks[y][x] + } + } + } + + blocks.indices.forEach { i -> + blocks[i] = rotated[i].copyOf() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/model/GamePieceType.kt b/app/src/main/java/com/pixelmintdrop/model/GamePieceType.kt new file mode 100644 index 0000000..17ad868 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/model/GamePieceType.kt @@ -0,0 +1,48 @@ +package com.pixelmintdrop.model + +/** + * Represents the different types of game pieces + */ +enum class GamePieceType { + I, J, L, O, S, T, Z; + + fun getBlocks(): Array> { + return when (this) { + I -> arrayOf( + arrayOf(false, false, false, false), + arrayOf(true, true, true, true), + arrayOf(false, false, false, false), + arrayOf(false, false, false, false) + ) + J -> arrayOf( + arrayOf(true, false, false), + arrayOf(true, true, true), + arrayOf(false, false, false) + ) + L -> arrayOf( + arrayOf(false, false, true), + arrayOf(true, true, true), + arrayOf(false, false, false) + ) + O -> arrayOf( + arrayOf(true, true), + arrayOf(true, true) + ) + S -> arrayOf( + arrayOf(false, true, true), + arrayOf(true, true, false), + arrayOf(false, false, false) + ) + T -> arrayOf( + arrayOf(false, true, false), + arrayOf(true, true, true), + arrayOf(false, false, false) + ) + Z -> arrayOf( + arrayOf(true, true, false), + arrayOf(false, true, true), + arrayOf(false, false, false) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/model/HighScore.kt b/app/src/main/java/com/pixelmintdrop/model/HighScore.kt similarity index 79% rename from app/src/main/java/com/mintris/model/HighScore.kt rename to app/src/main/java/com/pixelmintdrop/model/HighScore.kt index b4b6295..ef70516 100644 --- a/app/src/main/java/com/mintris/model/HighScore.kt +++ b/app/src/main/java/com/pixelmintdrop/model/HighScore.kt @@ -1,4 +1,4 @@ -package com.mintris.model +package com.pixelmintdrop.model data class HighScore( val name: String, diff --git a/app/src/main/java/com/mintris/model/HighScoreAdapter.kt b/app/src/main/java/com/pixelmintdrop/model/HighScoreAdapter.kt similarity index 97% rename from app/src/main/java/com/mintris/model/HighScoreAdapter.kt rename to app/src/main/java/com/pixelmintdrop/model/HighScoreAdapter.kt index de4ec52..bcbae60 100644 --- a/app/src/main/java/com/mintris/model/HighScoreAdapter.kt +++ b/app/src/main/java/com/pixelmintdrop/model/HighScoreAdapter.kt @@ -1,4 +1,4 @@ -package com.mintris.model +package com.pixelmintdrop.model import android.graphics.Color import android.view.LayoutInflater @@ -6,7 +6,7 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.recyclerview.widget.RecyclerView -import com.mintris.R +import com.pixelmintdrop.R class HighScoreAdapter : RecyclerView.Adapter() { private var highScores: List = emptyList() diff --git a/app/src/main/java/com/mintris/model/HighScoreManager.kt b/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt similarity index 93% rename from app/src/main/java/com/mintris/model/HighScoreManager.kt rename to app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt index 6fd7df9..bf35f52 100644 --- a/app/src/main/java/com/mintris/model/HighScoreManager.kt +++ b/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt @@ -1,4 +1,4 @@ -package com.mintris.model +package com.pixelmintdrop.model import android.content.Context import android.content.SharedPreferences @@ -12,7 +12,7 @@ class HighScoreManager(private val context: Context) { private val type: Type = object : TypeToken>() {}.type companion object { - private const val PREFS_NAME = "mintris_highscores" + private const val PREFS_NAME = "pixelmintdrop_highscores" private const val KEY_HIGHSCORES = "highscores" private const val MAX_HIGHSCORES = 5 } diff --git a/app/src/main/java/com/mintris/model/PlayerProgressionManager.kt b/app/src/main/java/com/pixelmintdrop/model/PlayerProgressionManager.kt similarity index 78% rename from app/src/main/java/com/mintris/model/PlayerProgressionManager.kt rename to app/src/main/java/com/pixelmintdrop/model/PlayerProgressionManager.kt index 0ddc2a7..6a2e066 100644 --- a/app/src/main/java/com/mintris/model/PlayerProgressionManager.kt +++ b/app/src/main/java/com/pixelmintdrop/model/PlayerProgressionManager.kt @@ -1,8 +1,7 @@ -package com.mintris.model +package com.pixelmintdrop.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 @@ -52,10 +51,11 @@ class PlayerProgressionManager(context: Context) { unlockedThemes.add(THEME_CLASSIC) } - // Add default block skin if nothing is unlocked - if (unlockedBlocks.isEmpty()) { - unlockedBlocks.add("block_skin_1") - } + // 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() } /** @@ -93,24 +93,31 @@ class PlayerProgressionManager(context: Context) { /** * 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() + fun calculateGameXP( + score: Int, + lines: Int, + level: Int, + timePlayedMs: Long, + quadCount: Int, + perfectClearCount: Int + ): Long { + // Calculate base XP from score + val scoreXP = score * BASE_SCORE_XP * level + + // Calculate XP from lines cleared + val linesXP = lines * BASE_LINES_XP * level + + // Calculate quad bonus + val quadBonus = quadCount * BASE_QUAD_BONUS * level + + // Calculate perfect clear bonus + val perfectClearBonus = perfectClearCount * BASE_PERFECT_CLEAR_BONUS * level + + // Calculate time bonus (convert ms to seconds) + val timeBonus = (timePlayedMs / 1000.0) * BASE_TIME_XP * level + + // Sum all XP components + return (scoreXP + linesXP + quadBonus + perfectClearBonus + timeBonus).toLong() } /** @@ -182,11 +189,27 @@ class PlayerProgressionManager(context: Context) { } } - // Check for block skin unlocks - if (level % 7 == 0 && level <= 35) { - val blockSkin = "block_skin_${level / 7}" - if (unlockedBlocks.add(blockSkin)) { - newRewards.add("Unlocked New Block Skin!") + // 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!") + } } } @@ -205,13 +228,12 @@ class PlayerProgressionManager(context: Context) { if (playerLevel >= 20) unlockedThemes.add(THEME_MINIMALIST) if (playerLevel >= 25) unlockedThemes.add(THEME_GALAXY) - // Check block skin unlocks - for (i in 1..5) { - val requiredLevel = i * 7 - if (playerLevel >= requiredLevel) { - unlockedBlocks.add("block_skin_$i") - } - } + // 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() @@ -269,14 +291,14 @@ class PlayerProgressionManager(context: Context) { // Add default theme unlockedThemes.add(THEME_CLASSIC) - // Add default block skin + // Add default block skin (Level 1) unlockedBlocks.add("block_skin_1") saveProgress() } companion object { - private const val PREFS_NAME = "mintris_progression" + private const val PREFS_NAME = "pixelmintdrop_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" @@ -312,6 +334,12 @@ class PlayerProgressionManager(context: Context) { THEME_MINIMALIST to 20, THEME_GALAXY to 25 ) + + private const val BASE_SCORE_XP = 0.1 // XP per score point + private const val BASE_LINES_XP = 100.0 // XP per line cleared + private const val BASE_QUAD_BONUS = 500.0 // Bonus XP for clearing 4 lines at once + private const val BASE_PERFECT_CLEAR_BONUS = 1000.0 // Bonus XP for perfect clear + private const val BASE_TIME_XP = 1.0 // XP per second played } /** diff --git a/app/src/main/java/com/pixelmintdrop/model/StatsManager.kt b/app/src/main/java/com/pixelmintdrop/model/StatsManager.kt new file mode 100644 index 0000000..66dcca5 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/model/StatsManager.kt @@ -0,0 +1,78 @@ +package com.pixelmintdrop.model + +import android.content.Context +import android.content.SharedPreferences + +/** + * Manages game statistics and high scores + */ +class StatsManager(context: Context) { + companion object { + private const val PREFS_NAME = "game_stats" + private const val KEY_HIGH_SCORE = "high_score" + private const val KEY_TOTAL_GAMES = "total_games" + private const val KEY_TOTAL_LINES = "total_lines" + private const val KEY_TOTAL_QUADS = "total_quads" + private const val KEY_TOTAL_PERFECT_CLEARS = "total_perfect_clears" + private const val KEY_SESSION_SCORE = "session_score" + private const val KEY_SESSION_LINES = "session_lines" + private const val KEY_SESSION_QUADS = "session_quads" + private const val KEY_SESSION_PERFECT_CLEARS = "session_perfect_clears" + } + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Session stats + private var sessionScore = 0 + private var sessionLines = 0 + private var sessionQuads = 0 + private var sessionPerfectClears = 0 + + fun getHighScore(): Int = prefs.getInt(KEY_HIGH_SCORE, 0) + fun getTotalGames(): Int = prefs.getInt(KEY_TOTAL_GAMES, 0) + fun getTotalLines(): Int = prefs.getInt(KEY_TOTAL_LINES, 0) + fun getTotalQuads(): Int = prefs.getInt(KEY_TOTAL_QUADS, 0) + fun getTotalPerfectClears(): Int = prefs.getInt(KEY_TOTAL_PERFECT_CLEARS, 0) + + fun getSessionScore(): Int = sessionScore + fun getSessionLines(): Int = sessionLines + fun getSessionQuads(): Int = sessionQuads + fun getSessionPerfectClears(): Int = sessionPerfectClears + + fun updateStats(score: Int, lines: Int, isQuad: Boolean, isPerfectClear: Boolean) { + // Update session stats + sessionScore += score + sessionLines += lines + if (isQuad) sessionQuads++ + if (isPerfectClear) sessionPerfectClears++ + + // Update high score if needed + if (sessionScore > getHighScore()) { + prefs.edit().putInt(KEY_HIGH_SCORE, sessionScore).apply() + } + + // Update total stats + prefs.edit() + .putInt(KEY_TOTAL_LINES, getTotalLines() + lines) + .putInt(KEY_TOTAL_QUADS, getTotalQuads() + if (isQuad) 1 else 0) + .putInt(KEY_TOTAL_PERFECT_CLEARS, getTotalPerfectClears() + if (isPerfectClear) 1 else 0) + .apply() + } + + fun startNewGame() { + // Increment total games counter + prefs.edit().putInt(KEY_TOTAL_GAMES, getTotalGames() + 1).apply() + } + + fun resetSession() { + sessionScore = 0 + sessionLines = 0 + sessionQuads = 0 + sessionPerfectClears = 0 + } + + fun resetAllStats() { + prefs.edit().clear().apply() + resetSession() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/model/Tetromino.kt b/app/src/main/java/com/pixelmintdrop/model/Tetromino.kt similarity index 99% rename from app/src/main/java/com/mintris/model/Tetromino.kt rename to app/src/main/java/com/pixelmintdrop/model/Tetromino.kt index 54bafb2..d0daf97 100644 --- a/app/src/main/java/com/mintris/model/Tetromino.kt +++ b/app/src/main/java/com/pixelmintdrop/model/Tetromino.kt @@ -1,7 +1,7 @@ -package com.mintris.model +package com.pixelmintdrop.model /** - * Represents a Tetris piece (Tetromino) + * Represents a game piece (Tetromino) */ enum class TetrominoType { I, J, L, O, S, T, Z diff --git a/app/src/main/java/com/pixelmintdrop/theme/ThemeManager.kt b/app/src/main/java/com/pixelmintdrop/theme/ThemeManager.kt new file mode 100644 index 0000000..d87a517 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/theme/ThemeManager.kt @@ -0,0 +1,52 @@ +package com.pixelmintdrop.theme + +import android.graphics.Color + +object ThemeManager { + // Theme colors + const val COLOR_CLASSIC_BACKGROUND = Color.BLACK + const val COLOR_CLASSIC_FOREGROUND = Color.WHITE + const val COLOR_CLASSIC_ACCENT = Color.CYAN + + const val COLOR_NEON_BACKGROUND = 0xFF0D0221.toInt() + const val COLOR_NEON_FOREGROUND = 0xFFFF00FF.toInt() + const val COLOR_NEON_ACCENT = 0xFF00FFFF.toInt() + + const val COLOR_MONOCHROME_BACKGROUND = 0xFF1A1A1A.toInt() + const val COLOR_MONOCHROME_FOREGROUND = Color.LTGRAY + const val COLOR_MONOCHROME_ACCENT = Color.WHITE + + const val COLOR_RETRO_BACKGROUND = 0xFF3F2832.toInt() + const val COLOR_RETRO_FOREGROUND = 0xFFFF5A5F.toInt() + const val COLOR_RETRO_ACCENT = 0xFFFFB400.toInt() + + const val COLOR_MINIMALIST_BACKGROUND = Color.WHITE + const val COLOR_MINIMALIST_FOREGROUND = Color.BLACK + const val COLOR_MINIMALIST_ACCENT = Color.DKGRAY + + const val COLOR_GALAXY_BACKGROUND = 0xFF0B0C10.toInt() + const val COLOR_GALAXY_FOREGROUND = 0xFF66FCF1.toInt() + const val COLOR_GALAXY_ACCENT = 0xFF45A29E.toInt() + + // Block colors for each piece type + const val COLOR_I_PIECE = 0xFF00F0F0.toInt() + const val COLOR_J_PIECE = 0xFF0000F0.toInt() + const val COLOR_L_PIECE = 0xFFF0A000.toInt() + const val COLOR_O_PIECE = 0xFFF0F000.toInt() + const val COLOR_S_PIECE = 0xFF00F000.toInt() + const val COLOR_T_PIECE = 0xFFA000F0.toInt() + const val COLOR_Z_PIECE = 0xFFF00000.toInt() + + // Ghost piece colors + const val COLOR_GHOST_PIECE = 0x40FFFFFF + const val COLOR_GHOST_PIECE_GLOW = 0x20FFFFFF + + // Grid colors + const val COLOR_GRID_LINE = 0x20FFFFFF + const val COLOR_GRID_BORDER = 0x40FFFFFF + + // Effect colors + const val COLOR_LINE_CLEAR_FLASH = 0x80FFFFFF + const val COLOR_PERFECT_CLEAR_FLASH = 0xFFFFD700.toInt() + const val COLOR_COMBO_FLASH = 0x60FFFFFF +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/ui/BlockSkinSelector.kt b/app/src/main/java/com/pixelmintdrop/ui/BlockSkinSelector.kt new file mode 100644 index 0000000..3e1e1c9 --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/ui/BlockSkinSelector.kt @@ -0,0 +1,431 @@ +package com.pixelmintdrop.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.pixelmintdrop.R +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() + // Ordered list of skin IDs for navigation + private val skinIdList = mutableListOf() + // 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, 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 { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/ui/GameUIManager.kt b/app/src/main/java/com/pixelmintdrop/ui/GameUIManager.kt new file mode 100644 index 0000000..41fa22f --- /dev/null +++ b/app/src/main/java/com/pixelmintdrop/ui/GameUIManager.kt @@ -0,0 +1,187 @@ +package com.pixelmintdrop.ui + +import android.content.Context +import android.graphics.Color +import android.view.View +import com.pixelmintdrop.databinding.ActivityMainBinding +import com.pixelmintdrop.model.PlayerProgressionManager + +/** + * Handles UI updates and state management for the game interface + * to reduce the responsibility of the MainActivity. + */ +class GameUIManager( + private val context: Context, + private val binding: ActivityMainBinding +) { + // UI state + private var currentScore = 0L + private var currentLevel = 1 + private var currentLines = 0 + + // Theme management + private var currentTheme = PlayerProgressionManager.THEME_CLASSIC + + /** + * Update the game state UI elements + */ + fun updateGameStateUI(score: Long, level: Int, lines: Int) { + // Update cached values + currentScore = score + currentLevel = level + currentLines = lines + + // Update UI + binding.scoreText.text = score.toString() + binding.currentLevelText.text = level.toString() + binding.linesText.text = lines.toString() + } + + /** + * Show the game over UI + */ + fun showGameOver(finalScore: Long) { + // Hide game UI elements + binding.gameControlsContainer.visibility = View.GONE + binding.holdPieceView.visibility = View.GONE + binding.nextPieceView.visibility = View.GONE + binding.pauseButton.visibility = View.GONE + binding.leftControlsPanel?.visibility = View.GONE + binding.rightControlsPanel?.visibility = View.GONE + + // Show game over container + binding.gameOverContainer.visibility = View.VISIBLE + binding.sessionScoreText.text = "Score: $finalScore" + } + + /** + * Hide the game over UI + */ + fun hideGameOver() { + binding.gameOverContainer.visibility = View.GONE + } + + /** + * Show the pause menu + */ + fun showPauseMenu() { + binding.pauseContainer.visibility = View.VISIBLE + } + + /** + * Hide the pause menu + */ + fun hidePauseMenu() { + binding.pauseContainer.visibility = View.GONE + } + + /** + * Update the music toggle UI based on current state + */ + fun updateMusicToggleUI(isMusicEnabled: Boolean) { + // This assumes there's a musicToggle view in the layout + // Modify as needed based on the actual UI + } + + /** + * Update the level selector display + */ + fun updateLevelSelector(selectedLevel: Int) { + // Assuming pauseLevelText is part of the LevelBadge component + // This may need to be adapted based on how the level badge works + } + + /** + * Show the game elements (views, controls) + */ + fun showGameElements() { + binding.gameView.visibility = View.VISIBLE + binding.gameControlsContainer.visibility = View.VISIBLE + binding.holdPieceView.visibility = View.VISIBLE + binding.nextPieceView.visibility = View.VISIBLE + binding.pauseButton.visibility = View.VISIBLE + + // These might not exist in the actual layout + // binding.leftControlsPanel?.visibility = View.VISIBLE + // binding.rightControlsPanel?.visibility = View.VISIBLE + } + + /** + * Hide the game elements + */ + fun hideGameElements() { + binding.gameControlsContainer.visibility = View.GONE + binding.holdPieceView.visibility = View.GONE + binding.nextPieceView.visibility = View.GONE + binding.pauseButton.visibility = View.GONE + + // These might not exist in the actual layout + // binding.leftControlsPanel?.visibility = View.GONE + // binding.rightControlsPanel?.visibility = View.GONE + } + + /** + * Apply a theme to the game UI + */ + fun applyTheme(themeId: String) { + currentTheme = themeId + + // Set background color based on theme + val backgroundColor = getThemeBackgroundColor(themeId) + binding.root.setBackgroundColor(backgroundColor) + + // Update title screen theme if it has a setTheme method + // binding.titleScreen.setTheme(themeId) + + // Set text colors based on theme + val isDarkTheme = backgroundColor == Color.BLACK || + Color.red(backgroundColor) < 50 && + Color.green(backgroundColor) < 50 && + Color.blue(backgroundColor) < 50 + + val textColor = if (isDarkTheme) Color.WHITE else Color.BLACK + updateTextColors(textColor) + } + + /** + * Update all text colors in the UI + */ + private fun updateTextColors(color: Int) { + binding.scoreText.setTextColor(color) + binding.currentLevelText.setTextColor(color) + binding.linesText.setTextColor(color) + + // Other text views might not exist or have different IDs + // Adapt based on the actual layout + } + + /** + * Get background color based on theme ID + */ + private fun getThemeBackgroundColor(themeId: String): Int { + return 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 + } + } + + /** + * Get the current score + */ + fun getCurrentScore(): Long = currentScore + + /** + * Get the current level + */ + fun getCurrentLevel(): Int = currentLevel + + /** + * Get the current lines cleared + */ + fun getCurrentLines(): Int = currentLines +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/ui/LevelBadge.kt b/app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt similarity index 98% rename from app/src/main/java/com/mintris/ui/LevelBadge.kt rename to app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt index 67665e4..3213417 100644 --- a/app/src/main/java/com/mintris/ui/LevelBadge.kt +++ b/app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt @@ -1,4 +1,4 @@ -package com.mintris.ui +package com.pixelmintdrop.ui import android.content.Context import android.graphics.Canvas diff --git a/app/src/main/java/com/mintris/ui/ProgressionScreen.kt b/app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt similarity index 97% rename from app/src/main/java/com/mintris/ui/ProgressionScreen.kt rename to app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt index 8c5d4d4..07d55cc 100644 --- a/app/src/main/java/com/mintris/ui/ProgressionScreen.kt +++ b/app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt @@ -1,4 +1,4 @@ -package com.mintris.ui +package com.pixelmintdrop.ui import android.animation.AnimatorSet import android.animation.ObjectAnimator @@ -12,8 +12,8 @@ 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 +import com.pixelmintdrop.R +import com.pixelmintdrop.model.PlayerProgressionManager /** * Screen that displays player progression, XP gain, and unlocked rewards @@ -304,4 +304,11 @@ class ProgressionScreen @JvmOverloads constructor( card?.setCardBackgroundColor(backgroundColor) } } + + /** + * Public method to handle continue action via gamepad + */ + fun performContinue() { + continueButton.performClick() + } } \ No newline at end of file diff --git a/app/src/main/java/com/mintris/ui/ThemeSelector.kt b/app/src/main/java/com/pixelmintdrop/ui/ThemeSelector.kt similarity index 55% rename from app/src/main/java/com/mintris/ui/ThemeSelector.kt rename to app/src/main/java/com/pixelmintdrop/ui/ThemeSelector.kt index 1653ab2..059fd6c 100644 --- a/app/src/main/java/com/mintris/ui/ThemeSelector.kt +++ b/app/src/main/java/com/pixelmintdrop/ui/ThemeSelector.kt @@ -1,4 +1,4 @@ -package com.mintris.ui +package com.pixelmintdrop.ui import android.content.Context import android.graphics.Color @@ -9,8 +9,10 @@ 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 com.pixelmintdrop.R +import com.pixelmintdrop.model.PlayerProgressionManager +import android.graphics.drawable.GradientDrawable +import android.util.Log /** * UI component for selecting game themes @@ -27,11 +29,19 @@ class ThemeSelector @JvmOverloads constructor( // Callback when a theme is selected var onThemeSelected: ((String) -> Unit)? = null - // Currently selected theme + // Currently selected theme (persisted) private var selectedTheme: String = PlayerProgressionManager.THEME_CLASSIC - // Theme cards + // Theme cards map (themeId -> CardView) private val themeCards = mutableMapOf() + // Ordered list of theme IDs for navigation + private val themeIdList = mutableListOf() + // 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 @@ -46,25 +56,47 @@ class ThemeSelector @JvmOverloads constructor( * Update the theme selector with unlocked themes */ fun updateThemes(unlockedThemes: Set, currentTheme: String) { - // Clear existing theme cards + // 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 - val allThemes = getThemes() + // 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 + // 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() } /** @@ -86,20 +118,6 @@ class ThemeSelector @JvmOverloads constructor( // Set card background color based on theme setCardBackgroundColor(themeInfo.primaryColor) - // Add more noticeable visual indicator for selected theme - if (isSelected) { - setContentPadding(4, 4, 4, 4) - // Create a gradient drawable for the border - val gradientDrawable = android.graphics.drawable.GradientDrawable().apply { - setColor(themeInfo.primaryColor) - setStroke(6, Color.WHITE) // Thicker border - cornerRadius = 12f - } - background = gradientDrawable - // Add glow effect via elevation - cardElevation = 12f - } - // Set card dimensions val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size) layoutParams = GridLayout.LayoutParams().apply { @@ -194,38 +212,11 @@ class ThemeSelector @JvmOverloads constructor( // Set up click listener only for unlocked themes if (isUnlocked) { card.setOnClickListener { - // Only trigger callback if this isn't already the selected theme - if (themeId != selectedTheme) { - // Update previously selected card - themeCards[selectedTheme]?.let { prevCard -> - prevCard.cardElevation = 2f - // Reset any special styling - prevCard.background = null - prevCard.setCardBackgroundColor(getThemes()[selectedTheme]?.primaryColor ?: Color.BLACK) - } - - // Update visual state of newly selected card - card.cardElevation = 12f - - // Flash animation for selection feedback - val flashColor = Color.WHITE - val originalColor = themeInfo.primaryColor - - // Create animator for flash effect - val flashAnimator = android.animation.ValueAnimator.ofArgb(flashColor, originalColor) - flashAnimator.duration = 300 // 300ms - flashAnimator.addUpdateListener { animator -> - val color = animator.animatedValue as Int - card.setCardBackgroundColor(color) - } - flashAnimator.start() - - // Update selected theme - selectedTheme = themeId - - // Notify listener - onThemeSelected?.invoke(themeId) - } + // 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 } } @@ -292,4 +283,132 @@ class ThemeSelector @JvmOverloads constructor( ) ) } + + /** + * 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 + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/mintris/ui/XPProgressBar.kt b/app/src/main/java/com/pixelmintdrop/ui/XPProgressBar.kt similarity index 98% rename from app/src/main/java/com/mintris/ui/XPProgressBar.kt rename to app/src/main/java/com/pixelmintdrop/ui/XPProgressBar.kt index 930da2f..f752e75 100644 --- a/app/src/main/java/com/mintris/ui/XPProgressBar.kt +++ b/app/src/main/java/com/pixelmintdrop/ui/XPProgressBar.kt @@ -1,4 +1,4 @@ -package com.mintris.ui +package com.pixelmintdrop.ui import android.animation.ValueAnimator import android.content.Context @@ -9,8 +9,6 @@ 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 diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 0000000..1280712 --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 5591e04..71bd239 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -5,7 +5,7 @@ android:viewportWidth="108" android:viewportHeight="108"> - + @@ -19,7 +19,7 @@ android:fillColor="#FFFFFF" android:pathData="M48,48h12v12h-12z" /> - + diff --git a/app/src/main/res/drawable/ic_leaderboard.xml b/app/src/main/res/drawable/ic_leaderboard.xml new file mode 100644 index 0000000..d7cfb64 --- /dev/null +++ b/app/src/main/res/drawable/ic_leaderboard.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..068da4e --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/menu_item_background.xml b/app/src/main/res/drawable/menu_item_background.xml new file mode 100644 index 0000000..4beb4d7 --- /dev/null +++ b/app/src/main/res/drawable/menu_item_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/menu_item_selected.xml b/app/src/main/res/drawable/menu_item_selected.xml new file mode 100644 index 0000000..3beb40d --- /dev/null +++ b/app/src/main/res/drawable/menu_item_selected.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/menu_section_background.xml b/app/src/main/res/drawable/menu_section_background.xml new file mode 100644 index 0000000..683c792 --- /dev/null +++ b/app/src/main/res/drawable/menu_section_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml new file mode 100644 index 0000000..eb8a729 --- /dev/null +++ b/app/src/main/res/layout-land/activity_main.xml @@ -0,0 +1,750 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +