diff --git a/README.md b/README.md index 5bd8440..15b03d7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# pixelmintdrop +# Mintris -A modern block-stacking puzzle game for Android, featuring smooth animations, responsive controls, and a beautiful minimalist design. +A modern Tetris implementation for Android, featuring smooth animations, responsive controls, and a beautiful minimalist design. ## Features ### Core Gameplay -- Classic block-stacking mechanics +- Classic Tetris 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 -- Quad (4 lines): 1200 points +- Tetris (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 Quad** - - 50% bonus (1.5x) for consecutive quad clears - - Resets if a non-quad clear is performed +3. **Back-to-Back Tetris** + - 50% bonus (1.5x) for consecutive Tetris clears + - Resets if a non-Tetris clear is performed 4. **Perfect Clear** - 2x for single line - 3x for double - 4x for triple - - 5x for quad + - 5x for Tetris - Awarded when clearing lines without leaving blocks 5. **All Clear** @@ -102,124 +102,11 @@ 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/pixelmintdrop.git +git clone https://github.com/cmclark00/mintris.git ``` 2. Open the project in Android Studio diff --git a/app/build.gradle b/app/build.gradle index 45c83db..da55975 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,13 +4,13 @@ plugins { } android { - namespace "com.pixelmintdrop" - compileSdk 35 + namespace 'com.mintris' + compileSdk 34 defaultConfig { - applicationId "com.pixelmintdrop" + applicationId "com.mintris" minSdk 30 - targetSdk 35 + targetSdk 34 versionCode 1 versionName "1.0" @@ -19,15 +19,13 @@ android { buildTypes { release { - minifyEnabled true - shrinkResources true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { viewBinding true - dataBinding true } compileOptions { @@ -40,15 +38,6 @@ 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 461f647..e64cabd 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -6,40 +6,7 @@ # http://developer.android.com/guide/developing/tools/proguard.html # Keep models intact --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 +-keep class com.mintris.model.** { *; } # 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 0d57748..924b03a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,46 +7,38 @@ + android:theme="@style/Theme.Mintris"> + android:excludeFromRecents="false"> - + android:exported="false" /> \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt b/app/src/main/java/com/mintris/HighScoreEntryActivity.kt similarity index 61% rename from app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt rename to app/src/main/java/com/mintris/HighScoreEntryActivity.kt index 09e2064..8829d52 100644 --- a/app/src/main/java/com/pixelmintdrop/HighScoreEntryActivity.kt +++ b/app/src/main/java/com/mintris/HighScoreEntryActivity.kt @@ -1,15 +1,17 @@ -package com.pixelmintdrop +package com.mintris import android.app.Activity import android.os.Bundle +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import android.view.View import androidx.appcompat.app.AppCompatActivity -import com.pixelmintdrop.databinding.HighScoreEntryBinding -import com.pixelmintdrop.model.HighScore -import com.pixelmintdrop.model.HighScoreManager -import com.pixelmintdrop.model.PlayerProgressionManager +import com.mintris.databinding.HighScoreEntryBinding +import com.mintris.model.HighScore +import com.mintris.model.HighScoreManager +import com.mintris.model.PlayerProgressionManager import android.graphics.Color -import android.view.KeyEvent -import android.view.InputDevice class HighScoreEntryActivity : AppCompatActivity() { private lateinit var binding: HighScoreEntryBinding @@ -37,55 +39,20 @@ class HighScoreEntryActivity : AppCompatActivity() { binding.scoreText.text = "Score: $score" binding.saveButton.setOnClickListener { - saveScore() - } - } - - private fun saveScore() { - // Only allow saving once - if (!hasSaved) { - val name = binding.nameInput.text.toString().trim() - if (name.isNotEmpty()) { - hasSaved = true - val highScore = HighScore(name, score, 1) - highScoreManager.addHighScore(highScore) - - // Set result and finish - setResult(Activity.RESULT_OK) - finish() - } - } - } - - // Override onKeyDown to handle gamepad buttons - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - // Check if it's a gamepad input - if (event != null && isGamepadDevice(event.device)) { - when (keyCode) { - KeyEvent.KEYCODE_BUTTON_A, - KeyEvent.KEYCODE_DPAD_CENTER -> { - // A button saves the score - saveScore() - return true - } - KeyEvent.KEYCODE_BUTTON_B -> { - // B button cancels - setResult(Activity.RESULT_CANCELED) + // 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() - 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 @@ -99,7 +66,8 @@ class HighScoreEntryActivity : AppCompatActivity() { } private fun loadThemePreference(): String { - return progressionManager.getSelectedTheme() + val prefs = getSharedPreferences("mintris_settings", MODE_PRIVATE) + return prefs.getString("selected_theme", PlayerProgressionManager.THEME_CLASSIC) ?: PlayerProgressionManager.THEME_CLASSIC } private fun applyTheme(themeId: String) { diff --git a/app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt b/app/src/main/java/com/mintris/HighScoresActivity.kt similarity index 75% rename from app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt rename to app/src/main/java/com/mintris/HighScoresActivity.kt index ea21392..ae525bf 100644 --- a/app/src/main/java/com/pixelmintdrop/HighScoresActivity.kt +++ b/app/src/main/java/com/mintris/HighScoresActivity.kt @@ -1,16 +1,17 @@ -package com.pixelmintdrop +package com.mintris import android.os.Bundle +import android.widget.Button +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager -import com.pixelmintdrop.databinding.HighScoresBinding -import com.pixelmintdrop.model.HighScoreAdapter -import com.pixelmintdrop.model.HighScoreManager -import com.pixelmintdrop.model.PlayerProgressionManager +import androidx.recyclerview.widget.RecyclerView +import com.mintris.databinding.HighScoresBinding +import com.mintris.model.HighScoreAdapter +import com.mintris.model.HighScoreManager +import com.mintris.model.PlayerProgressionManager import android.graphics.Color import android.util.Log -import android.view.KeyEvent -import android.view.InputDevice class HighScoresActivity : AppCompatActivity() { private lateinit var binding: HighScoresBinding @@ -57,7 +58,8 @@ class HighScoresActivity : AppCompatActivity() { } private fun loadThemePreference(): String { - return progressionManager.getSelectedTheme() + val prefs = getSharedPreferences("mintris_settings", MODE_PRIVATE) + return prefs.getString("selected_theme", PlayerProgressionManager.THEME_CLASSIC) ?: PlayerProgressionManager.THEME_CLASSIC } private fun applyTheme(themeId: String) { @@ -112,29 +114,4 @@ 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/mintris/MainActivity.kt b/app/src/main/java/com/mintris/MainActivity.kt new file mode 100644 index 0000000..2f39436 --- /dev/null +++ b/app/src/main/java/com/mintris/MainActivity.kt @@ -0,0 +1,733 @@ +package com.mintris + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +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.os.VibratorManager +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.mintris.databinding.ActivityMainBinding +import com.mintris.game.GameHaptics +import com.mintris.game.GameView +import com.mintris.game.NextPieceView +import com.mintris.game.TitleScreen +import android.view.HapticFeedbackConstants +import com.mintris.model.GameBoard +import com.mintris.audio.GameMusic +import com.mintris.model.HighScoreManager +import com.mintris.model.PlayerProgressionManager +import com.mintris.model.StatsManager +import com.mintris.ui.ProgressionScreen +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.view.KeyEvent + +class MainActivity : AppCompatActivity() { + + // 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 + + // 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) + + // Load and apply theme preference + currentTheme = loadThemePreference() + applyTheme(currentTheme) + + // Set up game view + gameView.setGameBoard(gameBoard) + gameView.setHaptics(gameHaptics) + + // Set up progression screen + progressionScreen = binding.progressionScreen + progressionScreen.visibility = View.GONE + progressionScreen.onContinue = { + progressionScreen.visibility = View.GONE + binding.gameOverContainer.visibility = View.VISIBLE + } + + // Set up theme selector + val themeSelector = binding.themeSelector + themeSelector.onThemeSelected = { themeId -> + // 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 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 -> + android.util.Log.d("MainActivity", "Received line clear callback: $lineCount lines") + // Use enhanced haptic feedback for line clears + if (isSoundEnabled) { + android.util.Log.d("MainActivity", "Sound is enabled, triggering haptic feedback") + try { + gameHaptics.vibrateForLineClear(lineCount) + android.util.Log.d("MainActivity", "Haptic feedback triggered successfully") + } catch (e: Exception) { + android.util.Log.e("MainActivity", "Error triggering haptic feedback", e) + } + } else { + android.util.Log.d("MainActivity", "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 + binding.gameOverContainer.visibility = View.GONE + progressionScreen.visibility = View.VISIBLE + progressionScreen.applyTheme(currentTheme) + progressionScreen.showProgress(progressionManager, xpGained, newRewards, currentTheme) + + // 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() + } + + /** + * 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) { + // Only apply if the theme is unlocked + if (!progressionManager.isThemeUnlocked(themeId)) return + + // Save the selected theme + currentTheme = themeId + saveThemePreference(themeId) + + // Apply theme to title screen if it's visible + if (titleScreen.visibility == View.VISIBLE) { + titleScreen.applyTheme(themeId) + } + + // Apply theme colors based on theme ID + when (themeId) { + PlayerProgressionManager.THEME_CLASSIC -> { + // Default black theme + binding.root.setBackgroundColor(Color.BLACK) + } + PlayerProgressionManager.THEME_NEON -> { + // Neon theme with dark purple background + binding.root.setBackgroundColor(Color.parseColor("#0D0221")) + } + PlayerProgressionManager.THEME_MONOCHROME -> { + // Monochrome dark gray + binding.root.setBackgroundColor(Color.parseColor("#1A1A1A")) + } + PlayerProgressionManager.THEME_RETRO -> { + // Retro arcade theme + binding.root.setBackgroundColor(Color.parseColor("#3F2832")) + } + PlayerProgressionManager.THEME_MINIMALIST -> { + // Minimalist white theme + binding.root.setBackgroundColor(Color.WHITE) + + // Update text colors for visibility + binding.scoreText.setTextColor(Color.BLACK) + binding.currentLevelText.setTextColor(Color.BLACK) + binding.linesText.setTextColor(Color.BLACK) + binding.comboText.setTextColor(Color.BLACK) + } + PlayerProgressionManager.THEME_GALAXY -> { + // Galaxy dark blue theme + binding.root.setBackgroundColor(Color.parseColor("#0B0C10")) + } + } + + // Apply theme to progression screen if it's visible and initialized + if (::progressionScreen.isInitialized && progressionScreen.visibility == View.VISIBLE) { + progressionScreen.applyTheme(themeId) + } + + // Apply theme color to the stats button + val textColor = getThemeColor(currentTheme) + binding.statsButton.setTextColor(textColor) + + // Update the game view to apply theme + gameView.invalidate() + } + + /** + * Save the selected theme in preferences + */ + private fun saveThemePreference(themeId: String) { + val prefs = getSharedPreferences("mintris_settings", Context.MODE_PRIVATE) + prefs.edit().putString("selected_theme", themeId).apply() + } + + /** + * Load the saved theme preference + */ + private fun loadThemePreference(): String { + val prefs = getSharedPreferences("mintris_settings", Context.MODE_PRIVATE) + return prefs.getString("selected_theme", PlayerProgressionManager.THEME_CLASSIC) ?: PlayerProgressionManager.THEME_CLASSIC + } + + /** + * 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/StatsActivity.kt b/app/src/main/java/com/mintris/StatsActivity.kt similarity index 89% rename from app/src/main/java/com/pixelmintdrop/StatsActivity.kt rename to app/src/main/java/com/mintris/StatsActivity.kt index 1652365..e7f6f3e 100644 --- a/app/src/main/java/com/pixelmintdrop/StatsActivity.kt +++ b/app/src/main/java/com/mintris/StatsActivity.kt @@ -1,11 +1,13 @@ -package com.pixelmintdrop +package com.mintris import android.os.Bundle +import android.widget.Button +import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import com.pixelmintdrop.databinding.ActivityStatsBinding -import com.pixelmintdrop.model.StatsManager -import com.pixelmintdrop.model.PlayerProgressionManager +import com.mintris.databinding.ActivityStatsBinding +import com.mintris.model.StatsManager +import com.mintris.model.PlayerProgressionManager import android.graphics.Color import java.text.SimpleDateFormat import java.util.* @@ -42,7 +44,8 @@ class StatsActivity : AppCompatActivity() { } private fun loadThemePreference(): String { - return progressionManager.getSelectedTheme() + val prefs = getSharedPreferences("mintris_settings", MODE_PRIVATE) + return prefs.getString("selected_theme", PlayerProgressionManager.THEME_CLASSIC) ?: PlayerProgressionManager.THEME_CLASSIC } private fun applyTheme(themeId: String) { @@ -78,7 +81,7 @@ class StatsActivity : AppCompatActivity() { binding.totalSinglesText.setTextColor(textColor) binding.totalDoublesText.setTextColor(textColor) binding.totalTriplesText.setTextColor(textColor) - binding.totalQuadsText.setTextColor(textColor) + binding.totalTetrisesText.setTextColor(textColor) binding.maxLevelText.setTextColor(textColor) binding.maxScoreText.setTextColor(textColor) binding.maxLinesText.setTextColor(textColor) @@ -116,7 +119,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.totalQuadsText.text = getString(R.string.quads, statsManager.getTotalQuads()) + binding.totalTetrisesText.text = getString(R.string.tetrises, statsManager.getTotalTetrises()) // Update best performance stats binding.maxLevelText.text = getString(R.string.max_level, statsManager.getMaxLevel()) diff --git a/app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt b/app/src/main/java/com/mintris/audio/GameMusic.kt similarity index 58% rename from app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt rename to app/src/main/java/com/mintris/audio/GameMusic.kt index 38a8615..fbb8abf 100644 --- a/app/src/main/java/com/pixelmintdrop/audio/GameMusic.kt +++ b/app/src/main/java/com/mintris/audio/GameMusic.kt @@ -1,22 +1,20 @@ -package com.pixelmintdrop.audio +package com.mintris.audio import android.content.Context import android.media.MediaPlayer import android.media.AudioAttributes import android.os.Build import android.util.Log -import com.pixelmintdrop.R +import com.mintris.R class GameMusic(private val context: Context) { private var mediaPlayer: MediaPlayer? = null - private var gameOverPlayer: MediaPlayer? = null private var isEnabled = true private var isPrepared = false init { try { setupMediaPlayer() - setupGameOverPlayer() } catch (e: Exception) { Log.e("GameMusic", "Error initializing: ${e.message}") } @@ -48,49 +46,6 @@ 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 { @@ -148,25 +103,14 @@ class GameMusic(private val context: Context) { fun isEnabled(): Boolean = isEnabled - /** - * Releases all media player resources to prevent memory leaks - */ - fun releaseResources() { + fun release() { try { - Log.d("GameMusic", "Releasing MediaPlayer resources") + Log.d("GameMusic", "Releasing MediaPlayer") mediaPlayer?.release() - gameOverPlayer?.release() mediaPlayer = null - gameOverPlayer = null isPrepared = false } catch (e: Exception) { - Log.e("GameMusic", "Error releasing music resources: ${e.message}") + Log.e("GameMusic", "Error releasing music: ${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/mintris/game/GameHaptics.kt b/app/src/main/java/com/mintris/game/GameHaptics.kt new file mode 100644 index 0000000..9dc1e87 --- /dev/null +++ b/app/src/main/java/com/mintris/game/GameHaptics.kt @@ -0,0 +1,77 @@ +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 + +class GameHaptics(private val context: Context) { + private val 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 + } + + 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 vibrateForLineClear(lineCount: Int) { + android.util.Log.d("GameHaptics", "Attempting to vibrate for $lineCount lines") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val duration = when (lineCount) { + 4 -> 200L // Tetris - doubled from 100L + 3 -> 160L // Triples - doubled from 80L + 2 -> 120L // Doubles - doubled from 60L + 1 -> 80L // Singles - doubled from 40L + else -> 0L + } + + val amplitude = when (lineCount) { + 4 -> 255 // Full amplitude for Tetris + 3 -> 230 // 90% amplitude for triples + 2 -> 180 // 70% amplitude for doubles + 1 -> 128 // 50% amplitude for singles + else -> 0 + } + + android.util.Log.d("GameHaptics", "Vibration parameters - Duration: ${duration}ms, Amplitude: $amplitude") + if (duration > 0 && amplitude > 0) { + try { + val vibrationEffect = VibrationEffect.createOneShot(duration, amplitude) + vibrator.vibrate(vibrationEffect) + android.util.Log.d("GameHaptics", "Vibration triggered successfully") + } catch (e: Exception) { + android.util.Log.e("GameHaptics", "Error triggering vibration", e) + } + } + } else { + android.util.Log.w("GameHaptics", "Device does not support vibration effects (Android < 8.0)") + } + } + + 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/GameView.kt b/app/src/main/java/com/mintris/game/GameView.kt new file mode 100644 index 0000000..95332db --- /dev/null +++ b/app/src/main/java/com/mintris/game/GameView.kt @@ -0,0 +1,821 @@ +package com.mintris.game + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.BlurMaskFilter +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.animation.LinearInterpolator +import android.view.WindowManager +import android.view.Display +import android.hardware.display.DisplayManager +import com.mintris.model.GameBoard +import com.mintris.model.Tetromino +import com.mintris.model.TetrominoType +import kotlin.math.abs +import kotlin.math.min + +/** + * GameView that renders the Tetris game and handles touch input + */ +class GameView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + // Game board model + private var gameBoard = GameBoard() + private var gameHaptics: GameHaptics? = null + + // Game state + private var isRunning = false + var isPaused = false // Changed from private to public to allow access from MainActivity + private var score = 0 + + // Callbacks + var onNextPieceChanged: (() -> Unit)? = null + + // Rendering + private val blockPaint = Paint().apply { + color = Color.WHITE + isAntiAlias = true + } + + private val ghostBlockPaint = Paint().apply { + color = Color.WHITE + alpha = 80 // 30% opacity + isAntiAlias = true + } + + private val gridPaint = Paint().apply { + color = Color.parseColor("#222222") // Very dark gray + alpha = 20 // Reduced from 40 to be more subtle + isAntiAlias = true + strokeWidth = 1f + style = Paint.Style.STROKE + maskFilter = null // Ensure no blur effect on grid lines + } + + private val glowPaint = Paint().apply { + color = Color.WHITE + alpha = 40 // Reduced from 80 for more subtlety + isAntiAlias = true + style = Paint.Style.STROKE + strokeWidth = 1.5f + maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER) + } + + private val blockGlowPaint = Paint().apply { + color = Color.WHITE + alpha = 60 + isAntiAlias = true + style = Paint.Style.FILL + maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER) + } + + 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 + } + + // 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() { + if (isRunning && !isPaused) { + update() + invalidate() + handler.postDelayed(this, gameBoard.dropInterval) + } + } + } + + // Touch parameters + private var lastTouchX = 0f + private var lastTouchY = 0f + private var startX = 0f + private var startY = 0f + private var lastTapTime = 0L + private var lastRotationTime = 0L + private var lastMoveTime = 0L + private var minSwipeVelocity = 1200 // Increased from 800 to require more deliberate swipes + private val maxTapMovement = 20f // Maximum movement allowed for a tap (in pixels) + private val minTapTime = 100L // Minimum time for a tap (in milliseconds) + private val rotationCooldown = 150L // Minimum time between rotations (in milliseconds) + private val moveCooldown = 50L // Minimum time between move haptics (in milliseconds) + private var lockedDirection: Direction? = null // Track the locked movement direction + private val minMovementThreshold = 0.75f // Minimum movement threshold relative to block size + private val directionLockThreshold = 2.5f // Increased from 1.5f to make direction locking more aggressive + private val isStrictDirectionLock = true // Enable strict direction locking to prevent diagonal inputs + private val minHardDropDistance = 1.5f // Minimum distance (in blocks) for hard drop gesture + + private enum class Direction { + HORIZONTAL, VERTICAL + } + + // Callback for game events + var onGameStateChanged: ((score: Int, level: Int, lines: Int) -> Unit)? = null + var onGameOver: ((score: Int) -> Unit)? = null + var onLineClear: ((Int) -> Unit)? = null // New callback for line clear events + var onPieceMove: (() -> Unit)? = null // New callback for piece movement + var onPieceLock: (() -> Unit)? = null // New callback for piece locking + + // Animation state + private var pulseAnimator: ValueAnimator? = null + private var pulseAlpha = 0f + private var isPulsing = false + private var linesToPulse = mutableListOf() // Track which lines are being cleared + + init { + // Start with paused state + pause() + + // Connect our callbacks to the GameBoard + gameBoard.onPieceMove = { onPieceMove?.invoke() } + gameBoard.onPieceLock = { onPieceLock?.invoke() } + gameBoard.onLineClear = { lineCount, clearedLines -> + android.util.Log.d("GameView", "Received line clear from GameBoard: $lineCount lines") + try { + onLineClear?.invoke(lineCount) + // Use the lines that were cleared directly + linesToPulse.clear() + linesToPulse.addAll(clearedLines) + android.util.Log.d("GameView", "Found ${linesToPulse.size} lines to pulse") + startPulseAnimation(lineCount) + android.util.Log.d("GameView", "Forwarded line clear callback") + } catch (e: Exception) { + android.util.Log.e("GameView", "Error forwarding line clear callback", e) + } + } + + // Force hardware acceleration - This is critical for performance + setLayerType(LAYER_TYPE_HARDWARE, null) + + // Set better frame rate using modern APIs + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + display?.let { disp -> + val refreshRate = disp.refreshRate + // Set game loop interval based on refresh rate, but don't go faster than the base interval + val targetFps = refreshRate.toInt() + if (targetFps > 0) { + gameBoard.dropInterval = gameBoard.dropInterval.coerceAtMost(1000L / targetFps) + } + } + + // Enable edge-to-edge rendering + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setSystemGestureExclusionRects(listOf(Rect(0, 0, width, height))) + } + } + + /** + * Start the game + */ + fun start() { + isPaused = false + isRunning = true + gameBoard.startGame() // Add this line to ensure a new piece is spawned + handler.post(gameLoopRunnable) + invalidate() + } + + /** + * Pause the game + */ + fun pause() { + isPaused = true + handler.removeCallbacks(gameLoopRunnable) + invalidate() + } + + /** + * Reset the game + */ + fun reset() { + isRunning = false + isPaused = true + gameBoard.reset() + gameBoard.startGame() // Add this line to ensure a new piece is spawned + handler.removeCallbacks(gameLoopRunnable) + invalidate() + } + + /** + * Update game state (called on game loop) + */ + private fun update() { + if (gameBoard.isGameOver) { + isRunning = false + isPaused = true + onGameOver?.invoke(gameBoard.score) + return + } + + // Move the current tetromino down automatically + gameBoard.moveDown() + + // Update UI with current game state + onGameStateChanged?.invoke(gameBoard.score, gameBoard.level, gameBoard.lines) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + // Force hardware acceleration - Critical for performance + setLayerType(LAYER_TYPE_HARDWARE, null) + + // 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 + android.util.Log.d("GameView", "Board dimensions: width=$width, height=$height, blockSize=$blockSize, boardLeft=$boardLeft, boardTop=$boardTop, totalHeight=$totalHeight") + } + + 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 board border glow + drawBoardBorder(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 glowing border around the playable area + */ + private fun drawBoardBorder(canvas: Canvas) { + val left = boardLeft + val top = boardTop + val right = boardLeft + gameBoard.width * blockSize + val bottom = boardTop + gameBoard.height * blockSize + + val rect = RectF(left, top, right, bottom) + + // Draw base border with increased glow + borderGlowPaint.apply { + alpha = 80 // Increased from 60 + maskFilter = BlurMaskFilter(16f, BlurMaskFilter.Blur.OUTER) // Increased from 8f + } + canvas.drawRect(rect, borderGlowPaint) + + // Draw pulsing border if animation is active + if (isPulsing) { + val pulseBorderPaint = Paint().apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 6f + (16f * pulseAlpha) // Increased from 4f+12f to 6f+16f + alpha = (255 * pulseAlpha).toInt() + isAntiAlias = true + maskFilter = BlurMaskFilter(32f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) // Increased from 24f to 32f + } + // Draw the border with a slight inset to prevent edge artifacts + val inset = 1f + canvas.drawRect( + left + inset, + top + inset, + right - inset, + bottom - inset, + pulseBorderPaint + ) + + // Add an additional outer glow for more dramatic effect + val outerGlowPaint = Paint().apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 2f + alpha = (128 * pulseAlpha).toInt() + isAntiAlias = true + maskFilter = BlurMaskFilter(48f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) + } + canvas.drawRect( + left - 4f, + top - 4f, + right + 4f, + bottom + 4f, + outerGlowPaint + ) + + // Add extra bright glow for side borders during line clear + val sideGlowPaint = Paint().apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 8f + (24f * pulseAlpha) // Thicker stroke for side borders + alpha = (255 * pulseAlpha).toInt() + isAntiAlias = true + maskFilter = BlurMaskFilter(64f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) // Larger blur for side borders + } + + // Draw left border with extra glow + canvas.drawLine( + left + inset, + top + inset, + left + inset, + bottom - inset, + sideGlowPaint + ) + + // Draw right border with extra glow + canvas.drawLine( + right - inset, + top + inset, + right - inset, + bottom - inset, + sideGlowPaint + ) + } + } + + /** + * Draw the grid lines (very subtle) + */ + private fun drawGrid(canvas: Canvas) { + // Save the canvas state to prevent any effects from affecting the grid + canvas.save() + + // Draw vertical grid lines + for (x in 0..gameBoard.width) { + val xPos = boardLeft + x * blockSize + canvas.drawLine( + xPos, boardTop, + xPos, boardTop + gameBoard.height * blockSize, + gridPaint + ) + } + + // Draw horizontal grid lines + for (y in 0..gameBoard.height) { + val yPos = boardTop + y * blockSize + canvas.drawLine( + boardLeft, yPos, + boardLeft + gameBoard.width * blockSize, yPos, + gridPaint + ) + } + + // Restore the canvas state + canvas.restore() + } + + /** + * Draw the locked blocks on the board + */ + private fun drawLockedBlocks(canvas: Canvas) { + for (y in 0 until gameBoard.height) { + for (x in 0 until gameBoard.width) { + if (gameBoard.isOccupied(x, y)) { + drawBlock(canvas, x, y, false, y in linesToPulse) + } + } + } + } + + /** + * Draw the currently active tetromino + */ + private fun drawActivePiece(canvas: Canvas) { + val piece = gameBoard.getCurrentPiece() ?: return + + 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 + + // Draw piece regardless of vertical position + if (boardX >= 0 && boardX < gameBoard.width) { + drawBlock(canvas, boardX, boardY, false, false) + } + } + } + } + } + + /** + * Draw the ghost piece (landing preview) + */ + private fun drawGhostPiece(canvas: Canvas) { + val piece = gameBoard.getCurrentPiece() ?: return + val ghostY = gameBoard.getGhostY() + + 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 + + // Draw ghost piece regardless of vertical position + if (boardX >= 0 && boardX < gameBoard.width) { + drawBlock(canvas, boardX, boardY, true, false) + } + } + } + } + } + + /** + * 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 + + // Save canvas state before drawing block effects + canvas.save() + + // 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.apply { + color = if (isGhost) Color.argb(30, 255, 255, 255) else Color.WHITE + 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) + + // 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() + } + + /** + * Check if the given board position is part of the current piece + */ + private fun isPositionInPiece(boardX: Int, boardY: Int, piece: Tetromino): Boolean { + for (y in 0 until piece.getHeight()) { + for (x in 0 until piece.getWidth()) { + if (piece.isBlockAt(x, y)) { + val pieceX = piece.x + x + val pieceY = piece.y + y + if (pieceX == boardX && pieceY == boardY) { + return true + } + } + } + } + return false + } + + /** + * Get color for tetromino type + */ + private fun getTetrominoColor(type: TetrominoType): Int { + return when (type) { + TetrominoType.I -> Color.CYAN + TetrominoType.J -> Color.BLUE + TetrominoType.L -> Color.rgb(255, 165, 0) // Orange + TetrominoType.O -> Color.YELLOW + TetrominoType.S -> Color.GREEN + TetrominoType.T -> Color.MAGENTA + TetrominoType.Z -> Color.RED + } + } + + // Custom touch event handling + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!isRunning || isPaused || gameBoard.isGameOver) { + return true + } + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + // Record start of touch + startX = event.x + startY = event.y + lastTouchX = event.x + lastTouchY = event.y + lockedDirection = null // Reset direction lock + + // Check for double tap (rotate) + val currentTime = System.currentTimeMillis() + if (currentTime - lastTapTime < 200) { // Reduced from 250ms for faster response + // Double tap detected, rotate the piece + if (currentTime - lastRotationTime >= rotationCooldown) { + gameBoard.rotate() + lastRotationTime = currentTime + invalidate() + } + } + lastTapTime = currentTime + } + + MotionEvent.ACTION_MOVE -> { + val deltaX = event.x - lastTouchX + val deltaY = event.y - lastTouchY + val currentTime = System.currentTimeMillis() + + // Determine movement direction if not locked + if (lockedDirection == null) { + val absDeltaX = abs(deltaX) + val absDeltaY = abs(deltaY) + + // Check if movement exceeds threshold + if (absDeltaX > blockSize * minMovementThreshold || absDeltaY > blockSize * minMovementThreshold) { + // Determine dominant direction with stricter criteria + if (absDeltaX > absDeltaY * directionLockThreshold) { + lockedDirection = Direction.HORIZONTAL + } else if (absDeltaY > absDeltaX * directionLockThreshold) { + lockedDirection = Direction.VERTICAL + } + // If strict direction lock is enabled and we couldn't determine a clear direction, don't set one + // This prevents diagonal movements from being recognized + } + } + + // 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.moveDown() + lastTouchY = event.y + if (currentTime - lastMoveTime >= moveCooldown) { + gameHaptics?.vibrateForPieceMove() + lastMoveTime = currentTime + } + invalidate() + } + } + null -> { + // No direction lock yet, don't process movement + } + } + } + + MotionEvent.ACTION_UP -> { + // Calculate movement speed for potential fling detection + val moveTime = System.currentTimeMillis() - lastTapTime + val deltaY = event.y - startY + val deltaX = event.x - startX + + // Only allow hard drops with a deliberate downward swipe + // Requires: predominantly vertical movement, minimum distance, and minimum velocity + if (moveTime > 0 && + deltaY > blockSize * minHardDropDistance && // Require longer swipe for hard drop + (deltaY / moveTime) * 1000 > minSwipeVelocity && + abs(deltaX) < abs(deltaY) * 0.3f) { // Require more purely vertical movement (reduced from 0.5f to 0.3f) + gameBoard.hardDrop() + invalidate() + } else if (moveTime < minTapTime && + abs(deltaY) < maxTapMovement && + abs(deltaX) < maxTapMovement) { + // Quick tap with minimal movement (rotation) + val currentTime = System.currentTimeMillis() + if (currentTime - lastRotationTime >= rotationCooldown) { + gameBoard.rotate() + lastRotationTime = currentTime + invalidate() + } + } + + // Reset direction lock + lockedDirection = null + } + } + + return true + } + + /** + * Get the current score + */ + fun getScore(): Int = gameBoard.score + + /** + * Get the current level + */ + fun getLevel(): Int = gameBoard.level + + /** + * Get the number of lines cleared + */ + fun getLines(): Int = gameBoard.lines + + /** + * Check if the game is over + */ + fun isGameOver(): Boolean = gameBoard.isGameOver + + /** + * Get the next piece that will be spawned + */ + fun getNextPiece(): Tetromino? { + return gameBoard.getNextPiece() + } + + /** + * Clean up resources when view is detached + */ + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + handler.removeCallbacks(gameLoopRunnable) + } + + /** + * Set the game board for this view + */ + fun setGameBoard(board: GameBoard) { + gameBoard = board + + // Reconnect callbacks to the new board + gameBoard.onPieceMove = { onPieceMove?.invoke() } + gameBoard.onPieceLock = { onPieceLock?.invoke() } + gameBoard.onLineClear = { lineCount, clearedLines -> + android.util.Log.d("GameView", "Received line clear from GameBoard: $lineCount lines") + try { + onLineClear?.invoke(lineCount) + // Use the lines that were cleared directly + linesToPulse.clear() + linesToPulse.addAll(clearedLines) + android.util.Log.d("GameView", "Found ${linesToPulse.size} lines to pulse") + startPulseAnimation(lineCount) + android.util.Log.d("GameView", "Forwarded line clear callback") + } catch (e: Exception) { + android.util.Log.e("GameView", "Error forwarding line clear callback", e) + } + } + + invalidate() + } + + /** + * Set the haptics handler for this view + */ + fun setHaptics(haptics: GameHaptics) { + gameHaptics = haptics + } + + /** + * Resume the game + */ + fun resume() { + if (!isRunning) { + isRunning = true + } + isPaused = false + + // Restart the game loop immediately + handler.removeCallbacks(gameLoopRunnable) + handler.post(gameLoopRunnable) + + // Force an update to ensure pieces move immediately + update() + invalidate() + } + + /** + * Start the pulse animation for line clear + */ + private fun startPulseAnimation(lineCount: Int) { + android.util.Log.d("GameView", "Starting pulse animation for $lineCount lines") + + // Cancel any existing animation + pulseAnimator?.cancel() + + // Create new animation + pulseAnimator = ValueAnimator.ofFloat(0f, 1f, 0f).apply { + duration = when (lineCount) { + 4 -> 2000L // Tetris - longer duration + 3 -> 1600L // Triples + 2 -> 1200L // Doubles + 1 -> 1000L // Singles + else -> 1000L + } + interpolator = LinearInterpolator() + addUpdateListener { animation -> + pulseAlpha = animation.animatedValue as Float + isPulsing = true + invalidate() + android.util.Log.d("GameView", "Pulse animation update: alpha = $pulseAlpha") + } + addListener(object : android.animation.AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: android.animation.Animator) { + isPulsing = false + pulseAlpha = 0f + linesToPulse.clear() + invalidate() + android.util.Log.d("GameView", "Pulse animation ended") + } + }) + } + pulseAnimator?.start() + } +} diff --git a/app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt b/app/src/main/java/com/mintris/game/NextPieceView.kt similarity index 57% rename from app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt rename to app/src/main/java/com/mintris/game/NextPieceView.kt index ae7272c..c82fe2c 100644 --- a/app/src/main/java/com/pixelmintdrop/game/HoldPieceView.kt +++ b/app/src/main/java/com/mintris/game/NextPieceView.kt @@ -1,70 +1,53 @@ -package com.pixelmintdrop.game +package com.mintris.game import android.content.Context -import android.graphics.BlurMaskFilter import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.RectF +import android.graphics.BlurMaskFilter import android.util.AttributeSet import android.view.View -import com.pixelmintdrop.model.GameBoard import kotlin.math.min /** - * View that displays the currently held piece + * Custom view to display the next Tetromino piece */ -class HoldPieceView @JvmOverloads constructor( +class NextPieceView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private var gameView: GameView? = null - private var gameBoard: GameBoard? = null - + // Rendering private val blockPaint = Paint().apply { color = Color.WHITE isAntiAlias = true - style = Paint.Style.FILL } - + private val glowPaint = Paint().apply { color = Color.WHITE - alpha = 40 + alpha = 30 isAntiAlias = true style = Paint.Style.STROKE strokeWidth = 1.5f - maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER) } - - private val blockGlowPaint = Paint().apply { - color = Color.WHITE - alpha = 60 - isAntiAlias = true - style = Paint.Style.FILL - maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER) - } - + /** - * Set the game view reference + * Set the game view to get the next piece from */ - fun setGameView(view: GameView) { - gameView = view - gameBoard = view.getGameBoard() + fun setGameView(gameView: GameView) { + this.gameView = gameView } - - /** - * Get the game board reference - */ - private fun getGameBoard(): GameBoard? = gameBoard - + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - // Get the held piece from game board - gameBoard?.let { - it.getHoldPiece()?.let { piece -> + // Get the next piece from game view + gameView?.let { + it.getNextPiece()?.let { piece -> val width = piece.getWidth() val height = piece.getHeight() @@ -92,7 +75,6 @@ class HoldPieceView @JvmOverloads constructor( glowPaint ) - // Draw the held piece for (y in 0 until height) { for (x in 0 until width) { if (piece.isBlockAt(x, y)) { @@ -101,29 +83,13 @@ class HoldPieceView @JvmOverloads constructor( val right = left + previewBlockSize val bottom = top + previewBlockSize - // Draw outer glow - blockGlowPaint.color = Color.WHITE - canvas.drawRect( - left - 2f, - top - 2f, - right + 2f, - bottom + 2f, - blockGlowPaint - ) + // Draw block with subtle glow + val rect = RectF(left + 1, top + 1, right - 1, bottom - 1) + canvas.drawRect(rect, blockPaint) - // Draw block - blockPaint.color = Color.WHITE - canvas.drawRect(left, top, right, bottom, blockPaint) - - // Draw inner glow - glowPaint.color = Color.WHITE - canvas.drawRect( - left + 1f, - top + 1f, - right - 1f, - bottom - 1f, - glowPaint - ) + // Draw subtle border glow + val glowRect = RectF(left, top, right, bottom) + canvas.drawRect(glowRect, glowPaint) } } } diff --git a/app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt b/app/src/main/java/com/mintris/game/TitleScreen.kt similarity index 70% rename from app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt rename to app/src/main/java/com/mintris/game/TitleScreen.kt index f9baa6d..4c7a80f 100644 --- a/app/src/main/java/com/pixelmintdrop/game/TitleScreen.kt +++ b/app/src/main/java/com/mintris/game/TitleScreen.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.game +package com.mintris.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.pixelmintdrop.model.HighScoreManager -import com.pixelmintdrop.model.HighScore -import com.pixelmintdrop.model.PlayerProgressionManager +import com.mintris.model.HighScoreManager +import com.mintris.model.HighScore +import com.mintris.model.PlayerProgressionManager import kotlin.math.abs import androidx.core.graphics.withTranslation import androidx.core.graphics.withScale @@ -33,7 +33,7 @@ class TitleScreen @JvmOverloads constructor( private val random = Random() private var width = 0 private var height = 0 - private val piecesToAdd = mutableListOf() + private val tetrominosToAdd = mutableListOf() private val highScoreManager = HighScoreManager(context) // Pre-allocate HighScoreManager // Touch handling variables @@ -46,12 +46,11 @@ class TitleScreen @JvmOverloads constructor( // Callback for when the user touches the screen var onStartGame: (() -> Unit)? = null - // Theme color and background color + // Theme color private var themeColor = Color.WHITE - private var backgroundColor = Color.BLACK - // Define piece shapes (I, O, T, S, Z, J, L) - private val pieceShapes = arrayOf( + // Define tetromino shapes (I, O, T, S, Z, J, L) + private val tetrominoShapes = arrayOf( // I arrayOf( intArrayOf(0, 0, 0, 0), @@ -96,8 +95,8 @@ class TitleScreen @JvmOverloads constructor( ) ) - // FallingPiece class to represent falling pieces - private class FallingPiece( + // Tetromino class to represent falling pieces + private class Tetromino( var x: Float, var y: Float, val shape: Array, @@ -106,12 +105,12 @@ class TitleScreen @JvmOverloads constructor( val rotation: Int = 0 ) - private val pieces = mutableListOf() + private val tetrominos = mutableListOf() init { // Title text settings titlePaint.apply { - color = themeColor + color = Color.WHITE textSize = 120f textAlign = Paint.Align.CENTER typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) @@ -120,7 +119,7 @@ class TitleScreen @JvmOverloads constructor( // "Touch to start" text settings promptPaint.apply { - color = themeColor + color = Color.WHITE textSize = 50f textAlign = Paint.Align.CENTER typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) @@ -130,7 +129,7 @@ class TitleScreen @JvmOverloads constructor( // High scores text settings highScorePaint.apply { - color = themeColor + color = Color.WHITE textSize = 70f textAlign = Paint.Align.LEFT // Changed to LEFT alignment typeface = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL) // Changed to monospace @@ -138,16 +137,16 @@ class TitleScreen @JvmOverloads constructor( alpha = 200 } - // General paint settings for pieces + // General paint settings for tetrominos (white) paint.apply { - color = themeColor + color = Color.WHITE style = Paint.Style.FILL isAntiAlias = true } - // Glow paint settings for pieces + // Glow paint settings for tetrominos glowPaint.apply { - color = themeColor + color = Color.WHITE style = Paint.Style.FILL isAntiAlias = true alpha = 60 @@ -159,66 +158,66 @@ class TitleScreen @JvmOverloads constructor( width = w height = h - // Clear existing pieces - pieces.clear() + // Clear existing tetrominos + tetrominos.clear() - // Initialize some pieces + // Initialize some tetrominos repeat(20) { - val piece = createRandomPiece() - pieces.add(piece) + val tetromino = createRandomTetromino() + tetrominos.add(tetromino) } } - private fun createRandomPiece(): FallingPiece { + private fun createRandomTetromino(): Tetromino { val x = random.nextFloat() * (width - 150) + 50 // Keep away from edges val y = -cellSize * 4 - (random.nextFloat() * height / 2) - val shapeIndex = random.nextInt(pieceShapes.size) - val shape = pieceShapes[shapeIndex] + val shapeIndex = random.nextInt(tetrominoShapes.size) + val shape = tetrominoShapes[shapeIndex] val speed = 1f + random.nextFloat() * 2f val scale = 0.8f + random.nextFloat() * 0.4f val rotation = random.nextInt(4) * 90 - return FallingPiece(x, y, shape, speed, scale, rotation) + return Tetromino(x, y, shape, speed, scale, rotation) } override fun onDraw(canvas: Canvas) { try { super.onDraw(canvas) - // Draw background using the current background color - canvas.drawColor(backgroundColor) + // Draw background + canvas.drawColor(Color.BLACK) - // Add any pending pieces - pieces.addAll(piecesToAdd) - piecesToAdd.clear() + // Add any pending tetrominos + tetrominos.addAll(tetrominosToAdd) + tetrominosToAdd.clear() - // Update and draw falling pieces - val piecesToRemove = mutableListOf() + // Update and draw falling tetrominos + val tetrominosToRemove = mutableListOf() - for (piece in pieces) { - piece.y += piece.speed + for (tetromino in tetrominos) { + tetromino.y += tetromino.speed - // Remove pieces that have fallen off the screen - if (piece.y > height) { - piecesToRemove.add(piece) - piecesToAdd.add(createRandomPiece()) + // Remove tetrominos that have fallen off the screen + if (tetromino.y > height) { + tetrominosToRemove.add(tetromino) + tetrominosToAdd.add(createRandomTetromino()) } else { try { - // 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) { + // Draw the tetromino + for (y in 0 until tetromino.shape.size) { + for (x in 0 until tetromino.shape.size) { + if (tetromino.shape[y][x] == 1) { val left = x * cellSize val top = y * cellSize val right = left + cellSize val bottom = top + cellSize // Draw block with glow effect - canvas.withTranslation(piece.x, piece.y) { - withScale(piece.scale, piece.scale) { - withRotation(piece.rotation.toFloat(), - piece.shape.size * cellSize / 2, - piece.shape.size * cellSize / 2) { + canvas.withTranslation(tetromino.x, tetromino.y) { + withScale(tetromino.scale, tetromino.scale) { + withRotation(tetromino.rotation.toFloat(), + tetromino.shape.size * cellSize / 2, + tetromino.shape.size * cellSize / 2) { // Draw glow canvas.drawRect(left - 8f, top - 8f, right + 8f, bottom + 8f, glowPaint) // Draw block @@ -230,22 +229,21 @@ class TitleScreen @JvmOverloads constructor( } } } catch (e: Exception) { - Log.e("TitleScreen", "Error drawing piece", e) + Log.e("TitleScreen", "Error drawing tetromino", e) } } } - // Remove pieces that fell off the screen - pieces.removeAll(piecesToRemove) + // Remove tetrominos that fell off the screen + tetrominos.removeAll(tetrominosToRemove) // Draw title val titleY = height * 0.4f - canvas.drawText("Pixel Mint Drop", width / 2f, titleY, titlePaint) + canvas.drawText("mintris", 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") @@ -253,7 +251,6 @@ 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 @@ -262,15 +259,8 @@ class TitleScreen @JvmOverloads constructor( } } - // 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) + // Draw "touch to start" prompt + canvas.drawText("touch to start", width / 2f, height * 0.7f, promptPaint) // Request another frame invalidate() @@ -292,10 +282,10 @@ class TitleScreen @JvmOverloads constructor( val deltaX = event.x - lastTouchX val deltaY = event.y - lastTouchY - // Update piece positions - for (piece in pieces) { - piece.x += deltaX * 0.5f - piece.y += deltaY * 0.5f + // Update tetromino positions + for (tetromino in tetrominos) { + tetromino.x += deltaX * 0.5f + tetromino.y += deltaY * 0.5f } lastTouchX = event.x @@ -350,7 +340,7 @@ class TitleScreen @JvmOverloads constructor( glowPaint.color = themeColor // Update background color - backgroundColor = when (themeId) { + setBackgroundColor(when (themeId) { PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221") PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A") @@ -358,29 +348,8 @@ class TitleScreen @JvmOverloads constructor( PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10") else -> Color.BLACK - } + }) invalidate() } - - /** - * Set the theme color for the title screen - */ - fun setThemeColor(color: Int) { - themeColor = color - titlePaint.color = color - promptPaint.color = color - highScorePaint.color = color - paint.color = color - glowPaint.color = color - invalidate() - } - - /** - * Set the background color for the title screen - */ - override fun setBackgroundColor(color: Int) { - backgroundColor = color - invalidate() - } } \ 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 new file mode 100644 index 0000000..5203109 --- /dev/null +++ b/app/src/main/java/com/mintris/model/GameBoard.kt @@ -0,0 +1,585 @@ +package com.mintris.model + +import kotlin.random.Random + +/** + * Represents the game board (grid) and manages game state + */ +class GameBoard( + val width: Int = 10, + val height: Int = 20 +) { + // 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 + + // 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 + + 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.values()) + 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() { + currentPiece = nextPiece + spawnNextPiece() + + // Center the piece horizontally + currentPiece?.apply { + x = (width - getWidth()) / 2 + y = 0 + + // Check if the piece can be placed (Game Over condition) + if (!canMove(0, 0)) { + isGameOver = true + } + } + } + + /** + * 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 + onPieceMove?.invoke() + true + } else { + lockPiece() + false + } + } + + /** + * Hard drop the current piece + */ + fun hardDrop() { + if (isHardDropInProgress || isPieceLocking) return // Prevent multiple hard drops + + isHardDropInProgress = true + val piece = currentPiece ?: return + + // Move piece down until it can't move anymore + while (canMove(0, 1)) { + piece.y++ + onPieceMove?.invoke() + } + + // 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) return // Prevent recursive locking + 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() + + // Spawn new piece immediately + spawnPiece() + + // Allow holding piece again after locking + canHold = true + + // Reset both states after everything is done + isPieceLocking = false + isHardDropInProgress = false + } + + /** + * 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-- + } + + // If lines were cleared, calculate score in background and trigger callback + if (shiftAmount > 0) { + android.util.Log.d("GameBoard", "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 { + android.util.Log.d("GameBoard", "Triggering onLineClear callback with $shiftAmount lines") + try { + onLineClear?.invoke(shiftAmount, linesToClear) // Pass the lines that were cleared + android.util.Log.d("GameBoard", "onLineClear callback completed successfully") + } catch (e: Exception) { + android.util.Log.e("GameBoard", "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 +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/model/HighScore.kt b/app/src/main/java/com/mintris/model/HighScore.kt similarity index 79% rename from app/src/main/java/com/pixelmintdrop/model/HighScore.kt rename to app/src/main/java/com/mintris/model/HighScore.kt index ef70516..b4b6295 100644 --- a/app/src/main/java/com/pixelmintdrop/model/HighScore.kt +++ b/app/src/main/java/com/mintris/model/HighScore.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.model +package com.mintris.model data class HighScore( val name: String, diff --git a/app/src/main/java/com/pixelmintdrop/model/HighScoreAdapter.kt b/app/src/main/java/com/mintris/model/HighScoreAdapter.kt similarity index 97% rename from app/src/main/java/com/pixelmintdrop/model/HighScoreAdapter.kt rename to app/src/main/java/com/mintris/model/HighScoreAdapter.kt index bcbae60..de4ec52 100644 --- a/app/src/main/java/com/pixelmintdrop/model/HighScoreAdapter.kt +++ b/app/src/main/java/com/mintris/model/HighScoreAdapter.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.model +package com.mintris.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.pixelmintdrop.R +import com.mintris.R class HighScoreAdapter : RecyclerView.Adapter() { private var highScores: List = emptyList() diff --git a/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt b/app/src/main/java/com/mintris/model/HighScoreManager.kt similarity index 93% rename from app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt rename to app/src/main/java/com/mintris/model/HighScoreManager.kt index bf35f52..6fd7df9 100644 --- a/app/src/main/java/com/pixelmintdrop/model/HighScoreManager.kt +++ b/app/src/main/java/com/mintris/model/HighScoreManager.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.model +package com.mintris.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 = "pixelmintdrop_highscores" + private const val PREFS_NAME = "mintris_highscores" private const val KEY_HIGHSCORES = "highscores" private const val MAX_HIGHSCORES = 5 } diff --git a/app/src/main/java/com/pixelmintdrop/model/PlayerProgressionManager.kt b/app/src/main/java/com/mintris/model/PlayerProgressionManager.kt similarity index 62% rename from app/src/main/java/com/pixelmintdrop/model/PlayerProgressionManager.kt rename to app/src/main/java/com/mintris/model/PlayerProgressionManager.kt index 6a2e066..e9260bd 100644 --- a/app/src/main/java/com/pixelmintdrop/model/PlayerProgressionManager.kt +++ b/app/src/main/java/com/mintris/model/PlayerProgressionManager.kt @@ -1,10 +1,10 @@ -package com.pixelmintdrop.model +package com.mintris.model import android.content.Context import android.content.SharedPreferences +import com.mintris.R import kotlin.math.pow import kotlin.math.roundToInt -import kotlin.math.min /** * Manages player progression, experience points, and unlockable rewards @@ -20,6 +20,7 @@ class PlayerProgressionManager(context: Context) { // Track unlocked rewards private val unlockedThemes = mutableSetOf() private val unlockedBlocks = mutableSetOf() + private val unlockedPowers = mutableSetOf() private val unlockedBadges = mutableSetOf() // XP gained in the current session @@ -40,22 +41,18 @@ class PlayerProgressionManager(context: Context) { // Load unlocked rewards val themesSet = prefs.getStringSet(KEY_UNLOCKED_THEMES, setOf()) ?: setOf() val blocksSet = prefs.getStringSet(KEY_UNLOCKED_BLOCKS, setOf()) ?: setOf() + val powersSet = prefs.getStringSet(KEY_UNLOCKED_POWERS, setOf()) ?: setOf() val badgesSet = prefs.getStringSet(KEY_UNLOCKED_BADGES, setOf()) ?: setOf() unlockedThemes.addAll(themesSet) unlockedBlocks.addAll(blocksSet) + unlockedPowers.addAll(powersSet) unlockedBadges.addAll(badgesSet) // Add default theme if nothing is unlocked if (unlockedThemes.isEmpty()) { unlockedThemes.add(THEME_CLASSIC) } - - // Always ensure default block skin is present (Level 1) - unlockedBlocks.add("block_skin_1") - - // Explicitly check and save all unlocks for the current level on load - checkAllUnlocksForCurrentLevel() } /** @@ -68,6 +65,7 @@ class PlayerProgressionManager(context: Context) { .putLong(KEY_TOTAL_XP_EARNED, totalXPEarned) .putStringSet(KEY_UNLOCKED_THEMES, unlockedThemes) .putStringSet(KEY_UNLOCKED_BLOCKS, unlockedBlocks) + .putStringSet(KEY_UNLOCKED_POWERS, unlockedPowers) .putStringSet(KEY_UNLOCKED_BADGES, unlockedBadges) .apply() } @@ -93,31 +91,23 @@ class PlayerProgressionManager(context: Context) { /** * Calculate XP from a game session based on score, lines, level, etc. */ - 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() + fun calculateGameXP(score: Int, lines: Int, level: Int, gameTime: Long, + tetrisCount: Int, perfectClearCount: Int): Long { + // Base XP from score with level multiplier + val scoreXP = (score * (1 + LEVEL_MULTIPLIER * level)).toLong() + + // XP from lines cleared + val linesXP = lines * XP_PER_LINE + + // XP from special moves + val tetrisBonus = tetrisCount * TETRIS_XP_BONUS + val perfectClearBonus = perfectClearCount * PERFECT_CLEAR_XP_BONUS + + // Time bonus (to reward longer gameplay) + val timeBonus = (gameTime / 60000) * TIME_XP_PER_MINUTE // XP per minute played + + // Calculate total XP + return scoreXP + linesXP + tetrisBonus + perfectClearBonus + timeBonus } /** @@ -189,64 +179,46 @@ class PlayerProgressionManager(context: Context) { } } - // Check for block skin unlocks (start from skin 2 at level 7) + // Check for power unlocks when (level) { - 7 -> { - if (unlockedBlocks.add("block_skin_2")) { - newRewards.add("Unlocked Neon Block Skin!") + 8 -> { + if (unlockedPowers.add(POWER_FREEZE_TIME)) { + newRewards.add("Unlocked Freeze Time Power!") } } - 14 -> { - if (unlockedBlocks.add("block_skin_3")) { - newRewards.add("Unlocked Retro Block Skin!") + 12 -> { + if (unlockedPowers.add(POWER_BLOCK_SWAP)) { + newRewards.add("Unlocked Block Swap Power!") } } - 21 -> { - if (unlockedBlocks.add("block_skin_4")) { - newRewards.add("Unlocked Minimalist Block Skin!") + 18 -> { + if (unlockedPowers.add(POWER_SAFE_LANDING)) { + newRewards.add("Unlocked Safe Landing Power!") } } - 28 -> { - if (unlockedBlocks.add("block_skin_5")) { - newRewards.add("Unlocked Galaxy Block Skin!") + 30 -> { + if (unlockedPowers.add(POWER_PERFECT_CLEAR)) { + newRewards.add("Unlocked Perfect Clear Power!") } } } + // 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!") + } + } + return newRewards } - /** - * Check and unlock any rewards the player should have based on their current level - * This ensures players don't miss unlocks if they level up multiple times at once - */ - private fun checkAllUnlocksForCurrentLevel() { - // Check theme unlocks - if (playerLevel >= 5) unlockedThemes.add(THEME_NEON) - if (playerLevel >= 10) unlockedThemes.add(THEME_MONOCHROME) - if (playerLevel >= 15) unlockedThemes.add(THEME_RETRO) - if (playerLevel >= 20) unlockedThemes.add(THEME_MINIMALIST) - if (playerLevel >= 25) unlockedThemes.add(THEME_GALAXY) - - // Check block skin unlocks (start from skin 2 at level 7) - // Skin 1 is default (added in loadProgress) - if (playerLevel >= 7) unlockedBlocks.add("block_skin_2") - if (playerLevel >= 14) unlockedBlocks.add("block_skin_3") - if (playerLevel >= 21) unlockedBlocks.add("block_skin_4") - if (playerLevel >= 28) unlockedBlocks.add("block_skin_5") - - // Save any newly unlocked items - saveProgress() - } - /** * Start a new progression session */ fun startNewSession() { sessionXPGained = 0 - - // Ensure all appropriate unlocks are available - checkAllUnlocksForCurrentLevel() } // Getters @@ -255,6 +227,7 @@ class PlayerProgressionManager(context: Context) { fun getXPForNextLevel(): Long = calculateXPForLevel(playerLevel) fun getSessionXPGained(): Long = sessionXPGained fun getUnlockedThemes(): Set = unlockedThemes.toSet() + fun getUnlockedPowers(): Set = unlockedPowers.toSet() fun getUnlockedBlocks(): Set = unlockedBlocks.toSet() fun getUnlockedBadges(): Set = unlockedBadges.toSet() @@ -265,6 +238,13 @@ class PlayerProgressionManager(context: Context) { return unlockedThemes.contains(themeId) } + /** + * Check if a specific power is unlocked + */ + fun isPowerUnlocked(powerId: String): Boolean { + return unlockedPowers.contains(powerId) + } + /** * Award a badge to the player */ @@ -286,38 +266,37 @@ class PlayerProgressionManager(context: Context) { unlockedThemes.clear() unlockedBlocks.clear() + unlockedPowers.clear() unlockedBadges.clear() // Add default theme unlockedThemes.add(THEME_CLASSIC) - // Add default block skin (Level 1) - unlockedBlocks.add("block_skin_1") - saveProgress() } companion object { - private const val PREFS_NAME = "pixelmintdrop_progression" + private const val PREFS_NAME = "mintris_progression" private const val KEY_PLAYER_LEVEL = "player_level" private const val KEY_PLAYER_XP = "player_xp" private const val KEY_TOTAL_XP_EARNED = "total_xp_earned" private const val KEY_UNLOCKED_THEMES = "unlocked_themes" private const val KEY_UNLOCKED_BLOCKS = "unlocked_blocks" + private const val KEY_UNLOCKED_POWERS = "unlocked_powers" private const val KEY_UNLOCKED_BADGES = "unlocked_badges" - private const val KEY_SELECTED_THEME = "selected_theme" - private const val KEY_SELECTED_BLOCK_SKIN = "selected_block_skin" - // XP constants - private const val BASE_XP = 3000L - private const val XP_CURVE_FACTOR = 2.0 - private const val LEVEL_MULTIPLIER = 0.03 - private const val XP_PER_LINE = 40L - private const val TETRIS_XP_BONUS = 150L - private const val PERFECT_CLEAR_XP_BONUS = 300L - private const val TIME_XP_PER_MINUTE = 20L + // XP curve parameters + private const val BASE_XP = 4000.0 // Base XP for level 1 (reduced from 5000) + private const val XP_CURVE_FACTOR = 1.9 // Exponential factor for XP curve (reduced from 2.2) - // Theme constants + // XP calculation constants + private const val LEVEL_MULTIPLIER = 0.15 // 15% bonus per level (increased from 10%) + private const val XP_PER_LINE = 15L // Increased from 10 + private const val TETRIS_XP_BONUS = 75L // Increased from 50 + private const val PERFECT_CLEAR_XP_BONUS = 250L // Increased from 200 + private const val TIME_XP_PER_MINUTE = 8L // Increased from 5 + + // Theme IDs with required levels const val THEME_CLASSIC = "theme_classic" const val THEME_NEON = "theme_neon" const val THEME_MONOCHROME = "theme_monochrome" @@ -335,11 +314,19 @@ class PlayerProgressionManager(context: Context) { 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 + // Power IDs + const val POWER_FREEZE_TIME = "power_freeze_time" + const val POWER_BLOCK_SWAP = "power_block_swap" + const val POWER_SAFE_LANDING = "power_safe_landing" + const val POWER_PERFECT_CLEAR = "power_perfect_clear" + + // Map of powers to required levels + val POWER_REQUIRED_LEVELS = mapOf( + POWER_FREEZE_TIME to 8, + POWER_BLOCK_SWAP to 12, + POWER_SAFE_LANDING to 18, + POWER_PERFECT_CLEAR to 30 + ) } /** @@ -350,34 +337,9 @@ class PlayerProgressionManager(context: Context) { } /** - * Set the selected block skin + * Get the required level for a specific power */ - fun setSelectedBlockSkin(skinId: String) { - if (unlockedBlocks.contains(skinId)) { - prefs.edit().putString(KEY_SELECTED_BLOCK_SKIN, skinId).commit() - } - } - - /** - * Get the selected block skin - */ - fun getSelectedBlockSkin(): String { - return prefs.getString(KEY_SELECTED_BLOCK_SKIN, "block_skin_1") ?: "block_skin_1" - } - - /** - * Set the selected theme - */ - fun setSelectedTheme(themeId: String) { - if (unlockedThemes.contains(themeId)) { - prefs.edit().putString(KEY_SELECTED_THEME, themeId).apply() - } - } - - /** - * Get the selected theme - */ - fun getSelectedTheme(): String { - return prefs.getString(KEY_SELECTED_THEME, THEME_CLASSIC) ?: THEME_CLASSIC + fun getRequiredLevelForPower(powerId: String): Int { + return POWER_REQUIRED_LEVELS[powerId] ?: 1 } } \ 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 new file mode 100644 index 0000000..cc5272f --- /dev/null +++ b/app/src/main/java/com/mintris/model/StatsManager.kt @@ -0,0 +1,196 @@ +package com.mintris.model + +import android.content.Context +import android.content.SharedPreferences + +class StatsManager(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Lifetime stats + private var totalGames: Int = 0 + private var totalScore: Long = 0 + private var totalLines: Int = 0 + private var totalPieces: Int = 0 + private var totalTime: Long = 0 + private var maxLevel: Int = 0 + private var maxScore: Int = 0 + private var maxLines: Int = 0 + + // Line clear stats (lifetime) + private var totalSingles: Int = 0 + private var totalDoubles: Int = 0 + private var totalTriples: Int = 0 + private var totalTetrises: Int = 0 + + // Session stats + private var sessionScore: Int = 0 + private var sessionLines: Int = 0 + private var sessionPieces: Int = 0 + private var sessionTime: Long = 0 + private var sessionLevel: Int = 0 + + // Line clear stats (session) + private var sessionSingles: Int = 0 + private var sessionDoubles: Int = 0 + private var sessionTriples: Int = 0 + private var sessionTetrises: Int = 0 + + init { + loadStats() + } + + private fun loadStats() { + totalGames = prefs.getInt(KEY_TOTAL_GAMES, 0) + totalScore = prefs.getLong(KEY_TOTAL_SCORE, 0) + totalLines = prefs.getInt(KEY_TOTAL_LINES, 0) + totalPieces = prefs.getInt(KEY_TOTAL_PIECES, 0) + totalTime = prefs.getLong(KEY_TOTAL_TIME, 0) + maxLevel = prefs.getInt(KEY_MAX_LEVEL, 0) + maxScore = prefs.getInt(KEY_MAX_SCORE, 0) + maxLines = prefs.getInt(KEY_MAX_LINES, 0) + + // Load line clear stats + totalSingles = prefs.getInt(KEY_TOTAL_SINGLES, 0) + totalDoubles = prefs.getInt(KEY_TOTAL_DOUBLES, 0) + totalTriples = prefs.getInt(KEY_TOTAL_TRIPLES, 0) + totalTetrises = prefs.getInt(KEY_TOTAL_TETRISES, 0) + } + + private fun saveStats() { + prefs.edit() + .putInt(KEY_TOTAL_GAMES, totalGames) + .putLong(KEY_TOTAL_SCORE, totalScore) + .putInt(KEY_TOTAL_LINES, totalLines) + .putInt(KEY_TOTAL_PIECES, totalPieces) + .putLong(KEY_TOTAL_TIME, totalTime) + .putInt(KEY_MAX_LEVEL, maxLevel) + .putInt(KEY_MAX_SCORE, maxScore) + .putInt(KEY_MAX_LINES, maxLines) + .putInt(KEY_TOTAL_SINGLES, totalSingles) + .putInt(KEY_TOTAL_DOUBLES, totalDoubles) + .putInt(KEY_TOTAL_TRIPLES, totalTriples) + .putInt(KEY_TOTAL_TETRISES, totalTetrises) + .apply() + } + + fun startNewSession() { + sessionScore = 0 + sessionLines = 0 + sessionPieces = 0 + sessionTime = 0 + sessionLevel = 0 + sessionSingles = 0 + sessionDoubles = 0 + sessionTriples = 0 + sessionTetrises = 0 + } + + fun updateSessionStats(score: Int, lines: Int, pieces: Int, time: Long, level: Int) { + sessionScore = score + sessionLines = lines + sessionPieces = pieces + sessionTime = time + sessionLevel = level + } + + fun recordLineClear(lineCount: Int) { + when (lineCount) { + 1 -> { + sessionSingles++ + totalSingles++ + } + 2 -> { + sessionDoubles++ + totalDoubles++ + } + 3 -> { + sessionTriples++ + totalTriples++ + } + 4 -> { + sessionTetrises++ + totalTetrises++ + } + } + } + + fun endSession() { + totalGames++ + totalScore += sessionScore + totalLines += sessionLines + totalPieces += sessionPieces + totalTime += sessionTime + + if (sessionLevel > maxLevel) maxLevel = sessionLevel + if (sessionScore > maxScore) maxScore = sessionScore + if (sessionLines > maxLines) maxLines = sessionLines + + saveStats() + } + + // Getters for lifetime stats + fun getTotalGames(): Int = totalGames + fun getTotalScore(): Long = totalScore + fun getTotalLines(): Int = totalLines + fun getTotalPieces(): Int = totalPieces + fun getTotalTime(): Long = totalTime + fun getMaxLevel(): Int = maxLevel + fun getMaxScore(): Int = maxScore + fun getMaxLines(): Int = maxLines + + // Getters for line clear stats (lifetime) + fun getTotalSingles(): Int = totalSingles + fun getTotalDoubles(): Int = totalDoubles + fun getTotalTriples(): Int = totalTriples + fun getTotalTetrises(): Int = totalTetrises + + // Getters for session stats + fun getSessionScore(): Int = sessionScore + fun getSessionLines(): Int = sessionLines + fun getSessionPieces(): Int = sessionPieces + fun getSessionTime(): Long = sessionTime + fun getSessionLevel(): Int = sessionLevel + + // Getters for line clear stats (session) + fun getSessionSingles(): Int = sessionSingles + fun getSessionDoubles(): Int = sessionDoubles + fun getSessionTriples(): Int = sessionTriples + fun getSessionTetrises(): Int = sessionTetrises + + fun resetStats() { + // Reset all lifetime stats + totalGames = 0 + totalScore = 0 + totalLines = 0 + totalPieces = 0 + totalTime = 0 + maxLevel = 0 + maxScore = 0 + maxLines = 0 + + // Reset line clear stats + totalSingles = 0 + totalDoubles = 0 + totalTriples = 0 + totalTetrises = 0 + + // Save the reset stats + saveStats() + } + + companion object { + private const val PREFS_NAME = "mintris_stats" + private const val KEY_TOTAL_GAMES = "total_games" + private const val KEY_TOTAL_SCORE = "total_score" + private const val KEY_TOTAL_LINES = "total_lines" + private const val KEY_TOTAL_PIECES = "total_pieces" + private const val KEY_TOTAL_TIME = "total_time" + private const val KEY_MAX_LEVEL = "max_level" + private const val KEY_MAX_SCORE = "max_score" + private const val KEY_MAX_LINES = "max_lines" + private const val KEY_TOTAL_SINGLES = "total_singles" + private const val KEY_TOTAL_DOUBLES = "total_doubles" + private const val KEY_TOTAL_TRIPLES = "total_triples" + private const val KEY_TOTAL_TETRISES = "total_tetrises" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelmintdrop/model/Tetromino.kt b/app/src/main/java/com/mintris/model/Tetromino.kt similarity index 99% rename from app/src/main/java/com/pixelmintdrop/model/Tetromino.kt rename to app/src/main/java/com/mintris/model/Tetromino.kt index d0daf97..54bafb2 100644 --- a/app/src/main/java/com/pixelmintdrop/model/Tetromino.kt +++ b/app/src/main/java/com/mintris/model/Tetromino.kt @@ -1,7 +1,7 @@ -package com.pixelmintdrop.model +package com.mintris.model /** - * Represents a game piece (Tetromino) + * Represents a Tetris piece (Tetromino) */ enum class TetrominoType { I, J, L, O, S, T, Z diff --git a/app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt b/app/src/main/java/com/mintris/ui/LevelBadge.kt similarity index 98% rename from app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt rename to app/src/main/java/com/mintris/ui/LevelBadge.kt index 3213417..67665e4 100644 --- a/app/src/main/java/com/pixelmintdrop/ui/LevelBadge.kt +++ b/app/src/main/java/com/mintris/ui/LevelBadge.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.ui +package com.mintris.ui import android.content.Context import android.graphics.Canvas diff --git a/app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt b/app/src/main/java/com/mintris/ui/ProgressionScreen.kt similarity index 78% rename from app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt rename to app/src/main/java/com/mintris/ui/ProgressionScreen.kt index 07d55cc..bc67809 100644 --- a/app/src/main/java/com/pixelmintdrop/ui/ProgressionScreen.kt +++ b/app/src/main/java/com/mintris/ui/ProgressionScreen.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.ui +package com.mintris.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.pixelmintdrop.R -import com.pixelmintdrop.model.PlayerProgressionManager +import com.mintris.R +import com.mintris.model.PlayerProgressionManager /** * Screen that displays player progression, XP gain, and unlocked rewards @@ -31,9 +31,6 @@ class ProgressionScreen @JvmOverloads constructor( private val rewardsContainer: LinearLayout private val continueButton: TextView - // Current theme - private var currentTheme: String = PlayerProgressionManager.THEME_CLASSIC - // Callback for when the player dismisses the screen var onContinue: (() -> Unit)? = null @@ -65,9 +62,6 @@ class ProgressionScreen @JvmOverloads constructor( newRewards: List, themeId: String = PlayerProgressionManager.THEME_CLASSIC ) { - // Update current theme - currentTheme = themeId - // Hide rewards container initially if there are no new rewards rewardsContainer.visibility = if (newRewards.isEmpty()) View.GONE else View.INVISIBLE @@ -80,39 +74,20 @@ class ProgressionScreen @JvmOverloads constructor( playerLevelText.text = "Player Level: $playerLevel" xpGainText.text = "+$xpGained XP" - // Update level up text visibility - val progressionTitle = findViewById(R.id.progression_title) - progressionTitle.visibility = if (newRewards.any { it.contains("Level") }) View.VISIBLE else View.GONE + // Begin animation sequence + xpProgressBar.setXPValues(playerLevel, currentXP, xpForNextLevel) - // Start with initial animations - AnimatorSet().apply { - // Fade in the XP gain text - val xpTextAnimator = ObjectAnimator.ofFloat(xpGainText, "alpha", 0f, 1f).apply { - duration = 800 - interpolator = AccelerateDecelerateInterpolator() - } - - // Set up the XP progress bar animation sequence - val xpBarAnimator = ObjectAnimator.ofFloat(xpProgressBar, "alpha", 0f, 1f).apply { - duration = 800 - interpolator = AccelerateDecelerateInterpolator() - } - - // Play animations in sequence - play(xpTextAnimator) - play(xpBarAnimator).after(xpTextAnimator) - start() + // Animate XP gain text entrance + val xpTextAnimator = ObjectAnimator.ofFloat(xpGainText, "alpha", 0f, 1f).apply { + duration = 500 } - // Set initial progress bar state - xpProgressBar.setXPValues(playerLevel, currentXP - xpGained, xpForNextLevel) - - // Animate the XP gain after a short delay + // Schedule animation for the XP bar after text appears postDelayed({ xpProgressBar.animateXPGain(xpGained, playerLevel, currentXP, xpForNextLevel) - }, 1000) // Increased delay to 1 second for better visual flow + }, 600) - // If there are new rewards, show them with animation after XP bar animation + // If there are new rewards, show them with animation if (newRewards.isNotEmpty()) { // Create reward cards rewardsContainer.removeAllViews() @@ -138,12 +113,18 @@ class ProgressionScreen @JvmOverloads constructor( card.animate() .alpha(1f) .translationY(0f) - .setDuration(600) // Increased duration for smoother animation - .setStartDelay((i * 200).toLong()) // Increased delay between cards + .setDuration(400) + .setStartDelay((i * 150).toLong()) .setInterpolator(OvershootInterpolator()) .start() } - }, 2500) // Increased delay to wait for XP bar animation to finish + }, 2000) // Wait for XP bar animation to finish + } + + // Start with initial animations + AnimatorSet().apply { + play(xpTextAnimator) + start() } } @@ -156,17 +137,8 @@ class ProgressionScreen @JvmOverloads constructor( cardElevation = 4f useCompatPadding = true - // Set background color based on current theme - val backgroundColor = when (currentTheme) { - PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK - PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221") - PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A") - PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832") - PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE - PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10") - else -> Color.BLACK - } - setCardBackgroundColor(backgroundColor) + // Default background color - will be adjusted based on theme + setCardBackgroundColor(Color.BLACK) layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, @@ -195,8 +167,6 @@ class ProgressionScreen @JvmOverloads constructor( * Apply the current theme to the progression screen */ fun applyTheme(themeId: String) { - currentTheme = themeId - // Get reference to the title text val progressionTitle = findViewById(R.id.progression_title) val rewardsTitle = findViewById(R.id.rewards_title) @@ -278,10 +248,10 @@ class ProgressionScreen @JvmOverloads constructor( } } - // Update XP progress bar theme color + // Set theme color on XP progress bar xpProgressBar.setThemeColor(xpThemeColor) - // Update reward card colors + // Update card colors for any existing reward cards updateRewardCardColors(themeId) } @@ -289,7 +259,8 @@ class ProgressionScreen @JvmOverloads constructor( * Update colors of existing reward cards to match the theme */ private fun updateRewardCardColors(themeId: String) { - val backgroundColor = when (themeId) { + // Color for card backgrounds based on theme + val cardBackgroundColor = when (themeId) { PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221") PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A") @@ -299,16 +270,28 @@ class ProgressionScreen @JvmOverloads constructor( else -> Color.BLACK } + // Text color for rewards based on theme + val rewardTextColor = when (themeId) { + PlayerProgressionManager.THEME_CLASSIC -> Color.WHITE + PlayerProgressionManager.THEME_NEON -> Color.parseColor("#FF00FF") + PlayerProgressionManager.THEME_MONOCHROME -> Color.LTGRAY + PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#FF5A5F") + PlayerProgressionManager.THEME_MINIMALIST -> Color.BLACK + PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#66FCF1") + else -> Color.WHITE + } + + // Update each card in the rewards container for (i in 0 until rewardsContainer.childCount) { val card = rewardsContainer.getChildAt(i) as? CardView - card?.setCardBackgroundColor(backgroundColor) + card?.let { + it.setCardBackgroundColor(cardBackgroundColor) + + // Update text color in the card + if (it.childCount > 0 && it.getChildAt(0) is TextView) { + (it.getChildAt(0) as TextView).setTextColor(rewardTextColor) + } + } } } - - /** - * 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/pixelmintdrop/ui/ThemeSelector.kt b/app/src/main/java/com/mintris/ui/ThemeSelector.kt similarity index 55% rename from app/src/main/java/com/pixelmintdrop/ui/ThemeSelector.kt rename to app/src/main/java/com/mintris/ui/ThemeSelector.kt index 059fd6c..1653ab2 100644 --- a/app/src/main/java/com/pixelmintdrop/ui/ThemeSelector.kt +++ b/app/src/main/java/com/mintris/ui/ThemeSelector.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.ui +package com.mintris.ui import android.content.Context import android.graphics.Color @@ -9,10 +9,8 @@ import android.widget.FrameLayout import android.widget.GridLayout import android.widget.TextView import androidx.cardview.widget.CardView -import com.pixelmintdrop.R -import com.pixelmintdrop.model.PlayerProgressionManager -import android.graphics.drawable.GradientDrawable -import android.util.Log +import com.mintris.R +import com.mintris.model.PlayerProgressionManager /** * UI component for selecting game themes @@ -29,19 +27,11 @@ class ThemeSelector @JvmOverloads constructor( // Callback when a theme is selected var onThemeSelected: ((String) -> Unit)? = null - // Currently selected theme (persisted) + // Currently selected theme private var selectedTheme: String = PlayerProgressionManager.THEME_CLASSIC - // Theme cards map (themeId -> CardView) + // Theme cards 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 @@ -56,47 +46,25 @@ class ThemeSelector @JvmOverloads constructor( * Update the theme selector with unlocked themes */ fun updateThemes(unlockedThemes: Set, currentTheme: String) { - // Clear existing theme cards and ID list + // Clear existing theme cards themesGrid.removeAllViews() themeCards.clear() - themeIdList.clear() // Update selected theme selectedTheme = currentTheme - focusedThemeId = currentTheme // Initially focus the selected theme - focusedIndex = -1 // Reset index - // Get all possible themes and their details, sorted for consistent order - val allThemes = getThemes().entries.sortedWith(compareBy({ it.value.unlockLevel }, { it.value.displayName })).associate { it.key to it.value } + // Get all possible themes and their details + val allThemes = getThemes() - // Add theme cards to the grid and build the ID list + // Add theme cards to the grid 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() } /** @@ -118,6 +86,20 @@ 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 { @@ -212,11 +194,38 @@ class ThemeSelector @JvmOverloads constructor( // Set up click listener only for unlocked themes if (isUnlocked) { card.setOnClickListener { - // Clicking directly selects the theme - Log.d("ThemeSelector", "Theme card clicked: $themeId (isUnlocked=$isUnlocked)") - focusedThemeId = themeId - focusedIndex = themeIdList.indexOf(themeId) - confirmSelection() // Directly confirm click selection + // 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) + } } } @@ -283,132 +292,4 @@ 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/pixelmintdrop/ui/XPProgressBar.kt b/app/src/main/java/com/mintris/ui/XPProgressBar.kt similarity index 98% rename from app/src/main/java/com/pixelmintdrop/ui/XPProgressBar.kt rename to app/src/main/java/com/mintris/ui/XPProgressBar.kt index f752e75..c50ab6d 100644 --- a/app/src/main/java/com/pixelmintdrop/ui/XPProgressBar.kt +++ b/app/src/main/java/com/mintris/ui/XPProgressBar.kt @@ -1,4 +1,4 @@ -package com.pixelmintdrop.ui +package com.mintris.ui import android.animation.ValueAnimator import android.content.Context @@ -9,6 +9,8 @@ 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 @@ -97,8 +99,6 @@ class XPProgressBar @JvmOverloads constructor( */ fun setThemeColor(color: Int) { themeColor = color - progressPaint.color = color - textPaint.color = color levelBadgePaint.color = color invalidate() } diff --git a/app/src/main/java/com/pixelmintdrop/MainActivity.kt b/app/src/main/java/com/pixelmintdrop/MainActivity.kt deleted file mode 100644 index 9384137..0000000 --- a/app/src/main/java/com/pixelmintdrop/MainActivity.kt +++ /dev/null @@ -1,2041 +0,0 @@ -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/pixelmintdrop/ThemeManager.kt b/app/src/main/java/com/pixelmintdrop/ThemeManager.kt deleted file mode 100644 index b2d1e1f..0000000 --- a/app/src/main/java/com/pixelmintdrop/ThemeManager.kt +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 45281c0..0000000 --- a/app/src/main/java/com/pixelmintdrop/accessibility/GameAccessibilityHelper.kt +++ /dev/null @@ -1,152 +0,0 @@ -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/pixelmintdrop/game/GameHaptics.kt b/app/src/main/java/com/pixelmintdrop/game/GameHaptics.kt deleted file mode 100644 index 7629065..0000000 --- a/app/src/main/java/com/pixelmintdrop/game/GameHaptics.kt +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index f998875..0000000 --- a/app/src/main/java/com/pixelmintdrop/game/GameLifecycleManager.kt +++ /dev/null @@ -1,154 +0,0 @@ -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/pixelmintdrop/game/GameView.kt b/app/src/main/java/com/pixelmintdrop/game/GameView.kt deleted file mode 100644 index 2a062ee..0000000 --- a/app/src/main/java/com/pixelmintdrop/game/GameView.kt +++ /dev/null @@ -1,1237 +0,0 @@ -package com.pixelmintdrop.game - -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.BlurMaskFilter -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.LinearGradient -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF -import android.graphics.Shader -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.util.AttributeSet -import android.util.Log -import android.view.MotionEvent -import android.view.View -import android.view.animation.LinearInterpolator -import android.hardware.display.DisplayManager -import android.view.Display -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 - -/** - * GameView that renders the block-stacking game and handles touch input - */ -class GameView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : View(context, attrs, defStyleAttr) { - - companion object { - private const val TAG = "GameView" - } - - // Game board model - private var gameBoard = GameBoard() - private var gameHaptics: GameHaptics? = null - - // Game state - private var isRunning = false - var isPaused = false - private var score = 0 - - // Current piece - private var currentPiece: GamePiece? = null - private var nextPiece: GamePiece? = null - - // Callbacks - var onNextPieceChanged: (() -> Unit)? = null - - // Rendering - private val blockPaint = Paint().apply { - color = Color.WHITE - 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 - isAntiAlias = true - } - - private val gridPaint = Paint().apply { - color = Color.parseColor("#222222") - alpha = 20 - isAntiAlias = true - strokeWidth = 1f - style = Paint.Style.STROKE - maskFilter = null - } - - private val glowPaint = Paint().apply { - color = Color.WHITE - alpha = 40 - isAntiAlias = true - style = Paint.Style.STROKE - strokeWidth = 1.5f - maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER) - } - - private val blockGlowPaint = Paint().apply { - color = Color.WHITE - alpha = 60 - isAntiAlias = true - style = Paint.Style.FILL - maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER) - } - - private val pulsePaint = Paint().apply { - color = Color.CYAN - alpha = 255 - isAntiAlias = true - style = Paint.Style.FILL - maskFilter = BlurMaskFilter(32f, BlurMaskFilter.Blur.OUTER) - } - - private val tmpPaint = Paint() - - private var blockSize = 0f - private var boardLeft = 0f - private var boardTop = 0f - - private val handler = Handler(Looper.getMainLooper()) - private val gameLoopRunnable = object : Runnable { - override fun run() { - if (isRunning && !isPaused) { - update() - invalidate() - handler.postDelayed(this, gameBoard.dropInterval) - } - } - } - - private var lastTouchX = 0f - private var lastTouchY = 0f - private var startX = 0f - private var startY = 0f - private var lastTapTime = 0L - private var lastRotationTime = 0L - private var lastMoveTime = 0L - private var lastHardDropTime = 0L // Track when the last hard drop occurred - private val hardDropCooldown = 250L // Reduced from 500ms to 250ms - private var touchFreezeUntil = 0L // Time until which touch events should be ignored - private val pieceLockFreezeTime = 300L // Time to freeze touch events after piece locks - private var minSwipeVelocity = 1200 // Increased from 800 to require more deliberate swipes - private val maxTapMovement = 30f // Increased from 20f to 30f for more lenient tap detection - private val minTapTime = 100L // Minimum time for a tap (in milliseconds) - private val rotationCooldown = 150L // Minimum time between rotations (in milliseconds) - private val moveCooldown = 50L // Minimum time between move haptics (in milliseconds) - private val doubleTapTimeout = 400L // Increased from 300ms to 400ms for more lenient double tap detection - private var lastTapX = 0f // X coordinate of last tap - private var lastTapY = 0f // Y coordinate of last tap - private var lastHoldTime = 0L // Track when the last hold occurred - private val holdCooldown = 250L // Minimum time between holds - private var lockedDirection: Direction? = null // Track the locked movement direction - private val minMovementThreshold = 0.3f // Reduced from 0.5f for more sensitive horizontal movement - private val directionLockThreshold = 1.0f // Reduced from 1.5f to make direction locking less aggressive - private val isStrictDirectionLock = true // Re-enabled strict direction locking to prevent diagonal inputs - private val minHardDropDistance = 2.5f // Increased from 1.5f to require more deliberate hard drops - private val minHoldDistance = 2.0f // Minimum distance (in blocks) for hold gesture - private val maxSoftDropDistance = 1.5f // Maximum distance for soft drop before considering hard drop - - // Block skin - private var currentBlockSkin: String = "block_skin_1" - private val blockSkinPaints = mutableMapOf() - private var currentThemeColor = Color.WHITE - - private enum class Direction { - HORIZONTAL, VERTICAL - } - - // Callback for game events - var onGameStateChanged: ((score: Int, level: Int, lines: Int) -> Unit)? = null - var onGameOver: ((score: Int) -> Unit)? = null - var onLineClear: ((Int) -> Unit)? = null // New callback for line clear events - var onPieceMove: (() -> Unit)? = null // New callback for piece movement - var onPieceLock: (() -> Unit)? = null // New callback for piece locking - - // Animation state - private var pulseAnimator: ValueAnimator? = null - 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 - strokeWidth = 2f - color = Color.WHITE - alpha = 180 // Increased from 100 for better visibility - } - - private val ghostBackgroundPaint = Paint().apply { - style = Paint.Style.FILL - color = Color.WHITE - alpha = 30 // Very light background for better contrast - } - - private val ghostBorderPaint = Paint().apply { - style = Paint.Style.STROKE - strokeWidth = 1f - color = Color.WHITE - alpha = 100 // Subtle border for better definition - } - - init { - // Start with paused state - pause() - - // Load saved block skin - 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 - gameBoard.onPieceMove = { onPieceMove?.invoke() } - gameBoard.onPieceLock = { - // Freeze touch events for a brief period after a piece locks - touchFreezeUntil = System.currentTimeMillis() + pieceLockFreezeTime - Log.d(TAG, "Piece locked - freezing touch events until ${touchFreezeUntil}") - onPieceLock?.invoke() - } - gameBoard.onLineClear = { lineCount, clearedLines -> - Log.d(TAG, "Received line clear from GameBoard: $lineCount lines") - try { - onLineClear?.invoke(lineCount) - // Use the lines that were cleared directly - linesToPulse.clear() - linesToPulse.addAll(clearedLines) - Log.d(TAG, "Found ${linesToPulse.size} lines to pulse") - startPulseAnimation(lineCount) - Log.d(TAG, "Forwarded line clear callback") - } catch (e: Exception) { - Log.e(TAG, "Error forwarding line clear callback", e) - } - } - - // Force hardware acceleration - This is critical for performance - setLayerType(LAYER_TYPE_HARDWARE, null) - - // Set better frame rate using modern APIs - val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - displayManager.getDisplay(Display.DEFAULT_DISPLAY) - } else { - displayManager.displays.firstOrNull() - } - display?.let { disp -> - val refreshRate = disp.refreshRate - // Set game loop interval based on refresh rate, but don't go faster than the base interval - val targetFps = refreshRate.toInt() - if (targetFps > 0) { - gameBoard.dropInterval = gameBoard.dropInterval.coerceAtMost(1000L / targetFps) - } - } - - // Enable edge-to-edge rendering - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setSystemGestureExclusionRects(listOf(Rect(0, 0, width, height))) - } - - // Initialize block skin paints - initializeBlockSkinPaints() - } - - /** - * Initialize paints for different block skins - */ - private fun initializeBlockSkinPaints() { - // Classic skin - blockSkinPaints["block_skin_1"] = Paint().apply { - color = Color.WHITE - isAntiAlias = true - style = Paint.Style.FILL - } - - // Neon skin - blockSkinPaints["block_skin_2"] = Paint().apply { - color = Color.parseColor("#FF00FF") - isAntiAlias = true - maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER) - } - - // Retro skin - blockSkinPaints["block_skin_3"] = Paint().apply { - color = Color.parseColor("#FF5A5F") - isAntiAlias = false // Pixelated look - style = Paint.Style.FILL - } - - // Minimalist skin - blockSkinPaints["block_skin_4"] = Paint().apply { - color = Color.BLACK - isAntiAlias = true - style = Paint.Style.FILL - } - - // Galaxy skin - blockSkinPaints["block_skin_5"] = Paint().apply { - color = Color.parseColor("#66FCF1") - isAntiAlias = true - maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER) - } - } - - /** - * Set the current block skin - */ - fun setBlockSkin(skinId: String) { - 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("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") - } - - /** - * Get the current block skin - */ - fun getCurrentBlockSkin(): String = currentBlockSkin - - /** - * 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 - handler.post(gameLoopRunnable) - invalidate() - } - - /** - * Pause the game - */ - fun pause() { - isPaused = true - handler.removeCallbacks(gameLoopRunnable) - invalidate() - } - - /** - * Reset the game - */ - 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) - invalidate() - } - - /** - * Update game state (called on game loop) - */ - private fun update() { - if (gameBoard.isGameOver) { - // 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 - } - - // Update the game state - gameBoard.update() - - // Update UI with current game state - onGameStateChanged?.invoke(gameBoard.score, gameBoard.level, gameBoard.lines) - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - - // Calculate block size based on view dimensions and board size - blockSize = minOf( - w / (gameBoard.width + 2f), // Add padding - h / (gameBoard.height + 2f) // Add padding - ) - - // Center the board - boardLeft = (w - gameBoard.width * blockSize) / 2f - boardTop = (h - gameBoard.height * blockSize) / 2f - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - // Draw the game board - drawBoard(canvas) - - // Draw ghost piece - drawGhostPiece(canvas) - - // Draw current piece - currentPiece?.let { piece -> - drawPiece(canvas, piece) - } - } - - /** - * Draw glowing border around the playable area - */ - private fun drawBoardBorder(canvas: Canvas) { - val left = boardLeft - val top = boardTop - val right = boardLeft + gameBoard.width * blockSize - val bottom = boardTop + gameBoard.height * blockSize - - val rect = RectF(left, top, right, bottom) - - // Draw base border with increased glow - borderGlowPaint.apply { - alpha = 80 // Increased from 60 - maskFilter = BlurMaskFilter(16f, BlurMaskFilter.Blur.OUTER) // Increased from 8f - } - canvas.drawRect(rect, borderGlowPaint) - - // Draw pulsing border if animation is active - if (isPulsing) { - val pulseBorderPaint = Paint().apply { - color = Color.WHITE - style = Paint.Style.STROKE - strokeWidth = 6f + (16f * pulseAlpha) // Increased from 4f+12f to 6f+16f - alpha = (255 * pulseAlpha).toInt() - isAntiAlias = true - maskFilter = BlurMaskFilter(32f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) // Increased from 24f to 32f - } - // Draw the border with a slight inset to prevent edge artifacts - val inset = 1f - canvas.drawRect( - left + inset, - top + inset, - right - inset, - bottom - inset, - pulseBorderPaint - ) - - // Add an additional outer glow for more dramatic effect - val outerGlowPaint = Paint().apply { - color = Color.WHITE - style = Paint.Style.STROKE - strokeWidth = 2f - alpha = (128 * pulseAlpha).toInt() - isAntiAlias = true - maskFilter = BlurMaskFilter(48f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) - } - canvas.drawRect( - left - 4f, - top - 4f, - right + 4f, - bottom + 4f, - outerGlowPaint - ) - - // Add extra bright glow for side borders during line clear - val sideGlowPaint = Paint().apply { - color = Color.WHITE - style = Paint.Style.STROKE - strokeWidth = 8f + (24f * pulseAlpha) // Thicker stroke for side borders - alpha = (255 * pulseAlpha).toInt() - isAntiAlias = true - maskFilter = BlurMaskFilter(64f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) // Larger blur for side borders - } - - // Draw left border with extra glow - canvas.drawLine( - left + inset, - top + inset, - left + inset, - bottom - inset, - sideGlowPaint - ) - - // Draw right border with extra glow - canvas.drawLine( - right - inset, - top + inset, - right - inset, - bottom - inset, - sideGlowPaint - ) - } - } - - /** - * Draw the grid lines (very subtle) - */ - private fun drawGrid(canvas: Canvas) { - // Save the canvas state to prevent any effects from affecting the grid - canvas.save() - - // Draw vertical grid lines - for (x in 0..gameBoard.width) { - val xPos = boardLeft + x * blockSize - canvas.drawLine( - xPos, boardTop, - xPos, boardTop + gameBoard.height * blockSize, - gridPaint - ) - } - - // Draw horizontal grid lines - for (y in 0..gameBoard.height) { - val yPos = boardTop + y * blockSize - canvas.drawLine( - boardLeft, yPos, - boardLeft + gameBoard.width * blockSize, yPos, - gridPaint - ) - } - - // Restore the canvas state - canvas.restore() - } - - /** - * Draw the locked blocks on the board - */ - private fun drawLockedBlocks(canvas: Canvas) { - for (y in 0 until gameBoard.height) { - for (x in 0 until gameBoard.width) { - if (gameBoard.isOccupied(x, y)) { - drawBlock(canvas, x, y, false, y in linesToPulse) - } - } - } - } - - /** - * Draw the currently active tetromino - */ - private fun drawActivePiece(canvas: Canvas) { - val piece = gameBoard.getCurrentPiece() ?: return - - 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 - - // Draw piece regardless of vertical position - if (boardX >= 0 && boardX < gameBoard.width) { - drawBlock(canvas, boardX, boardY, false, false) - } - } - } - } - } - - /** - * Draw the ghost piece (landing preview) - */ - private fun drawGhostPiece(canvas: Canvas) { - 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 - } - } - - 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() - - 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) - } - } - } - } - - /** - * Check if the given board position is part of the current piece - */ - private fun isPositionInPiece(boardX: Int, boardY: Int, piece: Tetromino): Boolean { - for (y in 0 until piece.getHeight()) { - for (x in 0 until piece.getWidth()) { - if (piece.isBlockAt(x, y)) { - val pieceX = piece.x + x - val pieceY = piece.y + y - if (pieceX == boardX && pieceY == boardY) { - return true - } - } - } - } - return false - } - - /** - * Get color for tetromino type - */ - private fun getTetrominoColor(type: TetrominoType): Int { - return when (type) { - TetrominoType.I -> Color.CYAN - TetrominoType.J -> Color.BLUE - TetrominoType.L -> Color.rgb(255, 165, 0) // Orange - TetrominoType.O -> Color.YELLOW - TetrominoType.S -> Color.GREEN - TetrominoType.T -> Color.MAGENTA - TetrominoType.Z -> Color.RED - } - } - - // Custom touch event handling - override fun onTouchEvent(event: MotionEvent): Boolean { - if (!isRunning || isPaused) return false - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - startX = event.x - startY = event.y - lastTouchX = event.x - lastTouchY = event.y - lastTapTime = System.currentTimeMillis() - return true - } - - MotionEvent.ACTION_MOVE -> { - val dx = event.x - lastTouchX - val dy = event.y - lastTouchY - - // Horizontal movement - if (abs(dx) > blockSize / 2) { - if (dx > 0) { - movePieceRight() - } else { - movePieceLeft() - } - lastTouchX = event.x - } - - // Vertical movement (soft drop) - if (dy > blockSize / 2) { - dropPiece() - lastTouchY = event.y - } - - return true - } - - MotionEvent.ACTION_UP -> { - val dx = event.x - startX - val dy = event.y - startY - val tapTime = System.currentTimeMillis() - lastTapTime - - // Detect tap for rotation - if (abs(dx) < blockSize / 2 && abs(dy) < blockSize / 2 && tapTime < 200) { - rotatePiece() - } - - // Detect swipe down for hard drop - if (dy > height / 4) { - hardDrop() - } - - return true - } - } - - return false - } - - /** - * Get the current score - */ - fun getScore(): Int = gameBoard.score - - /** - * Get the current level - */ - fun getLevel(): Int = gameBoard.level - - /** - * Get the number of lines cleared - */ - fun getLines(): Int = gameBoard.lines - - /** - * Check if the game is over - */ - fun isGameOver(): Boolean = gameBoard.isGameOver - - /** - * Get the next piece that will be spawned - */ - fun getNextPiece(): GamePiece? { - return gameBoard.getNextPiece() - } - - /** - * Get the game board instance - */ - 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 - } - - /** - * Set the game board for this view - */ - fun setGameBoard(board: GameBoard) { - gameBoard = board - - // Reconnect callbacks to the new board - gameBoard.onPieceMove = { onPieceMove?.invoke() } - gameBoard.onPieceLock = { onPieceLock?.invoke() } - gameBoard.onLineClear = { lineCount, clearedLines -> - Log.d(TAG, "Received line clear from GameBoard: $lineCount lines") - try { - onLineClear?.invoke(lineCount) - // Use the lines that were cleared directly - linesToPulse.clear() - linesToPulse.addAll(clearedLines) - Log.d(TAG, "Found ${linesToPulse.size} lines to pulse") - startPulseAnimation(lineCount) - Log.d(TAG, "Forwarded line clear callback") - } catch (e: Exception) { - Log.e(TAG, "Error forwarding line clear callback", e) - } - } - - invalidate() - } - - /** - * Set the haptics handler for this view - */ - fun setHaptics(haptics: GameHaptics) { - gameHaptics = haptics - } - - /** - * Resume the game - */ - fun resume() { - if (!isRunning) { - isRunning = true - } - isPaused = false - - // Restart the game loop immediately - handler.removeCallbacks(gameLoopRunnable) - handler.post(gameLoopRunnable) - - // Force an update to ensure pieces move immediately - update() - invalidate() - } - - /** - * Start the pulse animation for line clear - */ - private fun startPulseAnimation(lineCount: Int) { - Log.d(TAG, "Starting pulse animation for $lineCount lines") - - // Cancel any existing animation - pulseAnimator?.cancel() - - // Create new animation - pulseAnimator = ValueAnimator.ofFloat(0f, 1f, 0f).apply { - duration = when (lineCount) { - 4 -> 2000L // Tetris - longer duration - 3 -> 1600L // Triples - 2 -> 1200L // Doubles - 1 -> 1000L // Singles - else -> 1000L - } - interpolator = LinearInterpolator() - addUpdateListener { animation -> - pulseAlpha = animation.animatedValue as Float - isPulsing = true - invalidate() - Log.d(TAG, "Pulse animation update: alpha = $pulseAlpha") - } - addListener(object : android.animation.AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: android.animation.Animator) { - isPulsing = false - pulseAlpha = 0f - linesToPulse.clear() - invalidate() - Log.d(TAG, "Pulse animation ended") - } - }) - } - pulseAnimator?.start() - } - - /** - * Set the theme color for the game view - */ - fun setThemeColor(color: Int) { - currentThemeColor = color - blockPaint.color = color - ghostBlockPaint.color = color - glowPaint.color = color - blockGlowPaint.color = color - borderGlowPaint.color = color - pulsePaint.color = color - invalidate() - } - - /** - * Set the background color for the game view - */ - override fun setBackgroundColor(color: Int) { - 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 deleted file mode 100644 index 83718b2..0000000 --- a/app/src/main/java/com/pixelmintdrop/game/GamepadController.kt +++ /dev/null @@ -1,510 +0,0 @@ -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/pixelmintdrop/game/NextPieceView.kt b/app/src/main/java/com/pixelmintdrop/game/NextPieceView.kt deleted file mode 100644 index f6d3543..0000000 --- a/app/src/main/java/com/pixelmintdrop/game/NextPieceView.kt +++ /dev/null @@ -1,97 +0,0 @@ -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/pixelmintdrop/model/GameBoard.kt b/app/src/main/java/com/pixelmintdrop/model/GameBoard.kt deleted file mode 100644 index 35d7d06..0000000 --- a/app/src/main/java/com/pixelmintdrop/model/GameBoard.kt +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index 37e0268..0000000 --- a/app/src/main/java/com/pixelmintdrop/model/GamePiece.kt +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 17ad868..0000000 --- a/app/src/main/java/com/pixelmintdrop/model/GamePieceType.kt +++ /dev/null @@ -1,48 +0,0 @@ -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/pixelmintdrop/model/StatsManager.kt b/app/src/main/java/com/pixelmintdrop/model/StatsManager.kt deleted file mode 100644 index 66dcca5..0000000 --- a/app/src/main/java/com/pixelmintdrop/model/StatsManager.kt +++ /dev/null @@ -1,78 +0,0 @@ -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/pixelmintdrop/theme/ThemeManager.kt b/app/src/main/java/com/pixelmintdrop/theme/ThemeManager.kt deleted file mode 100644 index d87a517..0000000 --- a/app/src/main/java/com/pixelmintdrop/theme/ThemeManager.kt +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 3e1e1c9..0000000 --- a/app/src/main/java/com/pixelmintdrop/ui/BlockSkinSelector.kt +++ /dev/null @@ -1,431 +0,0 @@ -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 deleted file mode 100644 index 41fa22f..0000000 --- a/app/src/main/java/com/pixelmintdrop/ui/GameUIManager.kt +++ /dev/null @@ -1,187 +0,0 @@ -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/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml deleted file mode 100644 index 1280712..0000000 --- a/app/src/main/res/drawable/dialog_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ 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 71bd239..5591e04 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 deleted file mode 100644 index d7cfb64..0000000 --- a/app/src/main/res/drawable/ic_leaderboard.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 068da4e..0000000 --- a/app/src/main/res/drawable/ic_play.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 4beb4d7..0000000 --- a/app/src/main/res/drawable/menu_item_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ 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 deleted file mode 100644 index 3beb40d..0000000 --- a/app/src/main/res/drawable/menu_item_selected.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ 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 deleted file mode 100644 index 683c792..0000000 --- a/app/src/main/res/drawable/menu_section_background.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/preview_border.xml b/app/src/main/res/drawable/preview_border.xml deleted file mode 100644 index a28f95b..0000000 --- a/app/src/main/res/drawable/preview_border.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - \ 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 deleted file mode 100644 index eb8a729..0000000 --- a/app/src/main/res/layout-land/activity_main.xml +++ /dev/null @@ -1,750 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -