diff --git a/app/src/main/java/com/mintris/HighScoreEntryActivity.kt b/app/src/main/java/com/mintris/HighScoreEntryActivity.kt index 5290cf6..339c3b6 100644 --- a/app/src/main/java/com/mintris/HighScoreEntryActivity.kt +++ b/app/src/main/java/com/mintris/HighScoreEntryActivity.kt @@ -12,6 +12,8 @@ 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 @@ -39,20 +41,55 @@ class HighScoreEntryActivity : AppCompatActivity() { binding.scoreText.text = "Score: $score" binding.saveButton.setOnClickListener { - // Only allow saving once - if (!hasSaved) { - val name = binding.nameInput.text.toString().trim() - if (name.isNotEmpty()) { - hasSaved = true - val highScore = HighScore(name, score, 1) - highScoreManager.addHighScore(highScore) - - // Set result and finish - setResult(Activity.RESULT_OK) + saveScore() + } + } + + private fun saveScore() { + // Only allow saving once + if (!hasSaved) { + val name = binding.nameInput.text.toString().trim() + if (name.isNotEmpty()) { + hasSaved = true + val highScore = HighScore(name, score, 1) + highScoreManager.addHighScore(highScore) + + // Set result and finish + setResult(Activity.RESULT_OK) + finish() + } + } + } + + // Override onKeyDown to handle gamepad buttons + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + // Check if it's a gamepad input + if (event != null && isGamepadDevice(event.device)) { + when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_DPAD_CENTER -> { + // A button saves the score + saveScore() + return true + } + KeyEvent.KEYCODE_BUTTON_B -> { + // B button cancels + setResult(Activity.RESULT_CANCELED) finish() + return true } } } + + return super.onKeyDown(keyCode, event) + } + + // Helper function to check if the device is a gamepad + private fun isGamepadDevice(device: InputDevice?): Boolean { + if (device == null) return false + val sources = device.sources + return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK } // Prevent accidental back button press from causing issues diff --git a/app/src/main/java/com/mintris/HighScoresActivity.kt b/app/src/main/java/com/mintris/HighScoresActivity.kt index d91dfad..0fdc75d 100644 --- a/app/src/main/java/com/mintris/HighScoresActivity.kt +++ b/app/src/main/java/com/mintris/HighScoresActivity.kt @@ -12,6 +12,8 @@ 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 @@ -113,4 +115,29 @@ class HighScoresActivity : AppCompatActivity() { Log.e("HighScoresActivity", "Error in onResume", e) } } + + // Handle gamepad buttons + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + // Check if it's a gamepad input + if (event != null && isGamepadDevice(event.device)) { + when (keyCode) { + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BACK -> { + // B button or Back button returns to previous screen + finish() + return true + } + } + } + + return super.onKeyDown(keyCode, event) + } + + // Helper function to check if the device is a gamepad + private fun isGamepadDevice(device: InputDevice?): Boolean { + if (device == null) return false + val sources = device.sources + return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + } } \ No newline at end of file diff --git a/app/src/main/java/com/mintris/MainActivity.kt b/app/src/main/java/com/mintris/MainActivity.kt index 7aa7bb9..9ec72af 100644 --- a/app/src/main/java/com/mintris/MainActivity.kt +++ b/app/src/main/java/com/mintris/MainActivity.kt @@ -31,8 +31,23 @@ import android.util.Log import android.view.KeyEvent import android.os.Handler import android.os.Looper +import android.view.MotionEvent +import com.mintris.game.GamepadController +import android.view.InputDevice +import android.widget.Toast +import android.content.BroadcastReceiver +import android.content.IntentFilter +import android.app.AlertDialog +import android.graphics.drawable.ColorDrawable +import androidx.core.content.ContextCompat +import android.widget.Button +import android.widget.ScrollView +import android.widget.ImageButton -class MainActivity : AppCompatActivity() { +class MainActivity : AppCompatActivity(), + GamepadController.GamepadConnectionListener, + GamepadController.GamepadMenuListener, + GamepadController.GamepadNavigationListener { companion object { private const val TAG = "MainActivity" @@ -51,6 +66,7 @@ class MainActivity : AppCompatActivity() { 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 @@ -65,6 +81,28 @@ class MainActivity : AppCompatActivity() { // 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() override fun onCreate(savedInstanceState: Bundle?) { // Register activity result launcher for high score entry @@ -92,6 +130,23 @@ class MainActivity : AppCompatActivity() { progressionManager = PlayerProgressionManager(this) themeSelector = binding.themeSelector blockSkinSelector = binding.blockSkinSelector + pauseMenuScrollView = binding.pauseMenuScrollView + + // 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 @@ -221,8 +276,44 @@ class MainActivity : AppCompatActivity() { updateUI(score, level, lines) } - gameView.onGameOver = { score -> - showGameOver(score) + // Track pieces placed using callback + gameBoard.onPieceLock = { + // Increment pieces placed counter + piecesPlaced++ + + // Provide haptic feedback + gameHaptics.vibrateForPieceLock() + } + + 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(finalScore, gameBoard.lines, currentLevel, timePlayedMs, statsManager.getSessionTetrises(), 0) + 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 -> @@ -250,13 +341,6 @@ class MainActivity : AppCompatActivity() { } } - gameView.onPieceLock = { - if (isSoundEnabled) { - gameHaptics.vibrateForPieceLock() - } - piecesPlaced++ - } - // Set up button click listeners with haptic feedback binding.playAgainButton.setOnClickListener { gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY) @@ -326,6 +410,9 @@ class MainActivity : AppCompatActivity() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.setDecorFitsSystemWindows(false) } + + // Initialize pause menu items for gamepad navigation + initPauseMenuNavigation() } /** @@ -404,6 +491,12 @@ class MainActivity : AppCompatActivity() { Log.d("MainActivity", "Triggering game over animation") gameView.startGameOverAnimation() + // Hide game UI elements in landscape mode + if (resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) { + binding.leftControlsPanel?.visibility = View.GONE + binding.rightControlsPanel?.visibility = View.GONE + } + // Wait a moment before showing progression screen to let animation be visible Handler(Looper.getMainLooper()).postDelayed({ // Show progression screen first with XP animation @@ -507,7 +600,7 @@ class MainActivity : AppCompatActivity() { unlockedThemes = progressionManager.getUnlockedThemes(), currentTheme = currentTheme ) - + // Update block skin selector - handle both standard and landscape versions blockSkinSelector.updateBlockSkins( progressionManager.getUnlockedBlocks(), @@ -522,6 +615,15 @@ class MainActivity : AppCompatActivity() { gameView.getCurrentBlockSkin(), progressionManager.getPlayerLevel() ) + + // Initialize pause menu navigation + initPauseMenuNavigation() + + // Reset and highlight first selectable menu item + if (pauseMenuItems.isNotEmpty()) { + currentMenuSelection = if (binding.resumeButton.visibility == View.VISIBLE) 0 else 1 + highlightMenuItem(currentMenuSelection) + } } /** @@ -567,14 +669,110 @@ class MainActivity : AppCompatActivity() { ) } + /** + * Start a new game + */ private fun startGame() { - gameView.start() - gameMusic.setEnabled(isMusicEnabled) + // Reset pieces placed counter + piecesPlaced = 0 + + // Set initial game state + currentScore = 0 + currentLevel = selectedLevel + 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() + + // Show game elements + gameView.visibility = View.VISIBLE + binding.gameControlsContainer.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 -> + currentScore = score + currentLevel = level + + binding.scoreText.text = "$score" + binding.currentLevelText.text = "$level" + binding.linesText.text = "$lines" + binding.comboText.text = gameBoard.getCombo().toString() + } + + 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(finalScore, gameBoard.lines, currentLevel, timePlayedMs, statsManager.getSessionTetrises(), 0) + 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() } - gameStartTime = System.currentTimeMillis() - piecesPlaced = 0 + + // Start the game + gameView.start() + gameMusic.setEnabled(isMusicEnabled) + + // Reset session stats statsManager.startNewSession() progressionManager.startNewSession() gameBoard.updateLevel(selectedLevel) @@ -602,10 +800,25 @@ class MainActivity : AppCompatActivity() { 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() @@ -773,10 +986,110 @@ class MainActivity : AppCompatActivity() { } /** - * Completely block the hardware back button during gameplay + * 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.mintris.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.mintris.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 back button is pressed + 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) { // Handle back button press as a pause action during gameplay if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { @@ -797,9 +1110,24 @@ class MainActivity : AppCompatActivity() { 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) @@ -811,4 +1139,160 @@ class MainActivity : AppCompatActivity() { // Display progression data progressionScreen.showProgress(progressionManager, xpGained, newRewards, currentTheme) } + + /** + * Implements GamepadMenuListener to handle start button press + */ + override fun onPauseRequested() { + runOnUiThread { + 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() + } + } + } + + override fun onMenuDown() { + runOnUiThread { + if (binding.pauseContainer.visibility == View.VISIBLE) { + moveMenuSelectionDown() + } + } + } + + override fun onMenuSelect() { + runOnUiThread { + if (binding.pauseContainer.visibility == View.VISIBLE) { + activateSelectedMenuItem() + } else if (titleScreen.visibility == View.VISIBLE) { + titleScreen.performClick() + } else if (progressionScreen.visibility == View.VISIBLE) { + // Handle continue in progression screen + progressionScreen.performContinue() + } else if (binding.gameOverContainer.visibility == View.VISIBLE) { + // Handle play again in game over screen + binding.playAgainButton.performClick() + } + } + } + + /** + * Initialize pause menu items for gamepad navigation + */ + private fun initPauseMenuNavigation() { + pauseMenuItems.clear() + pauseMenuItems.add(binding.resumeButton) + pauseMenuItems.add(binding.pauseStartButton) + pauseMenuItems.add(binding.pauseRestartButton) + pauseMenuItems.add(binding.highScoresButton) + pauseMenuItems.add(binding.statsButton) + pauseMenuItems.add(binding.pauseLevelUpButton) + pauseMenuItems.add(binding.pauseLevelDownButton) + pauseMenuItems.add(binding.settingsButton) + pauseMenuItems.add(binding.musicToggle) + + // Add theme selector if present + val themeSelector = binding.themeSelector + if (themeSelector.visibility == View.VISIBLE) { + pauseMenuItems.add(themeSelector) + } + + // Add block skin selector if present + val blockSkinSelector = binding.blockSkinSelector + if (blockSkinSelector.visibility == View.VISIBLE) { + pauseMenuItems.add(blockSkinSelector) + } + } + + /** + * Highlight the currently selected menu item + */ + private fun highlightMenuItem(index: Int) { + // Reset all items to normal state + pauseMenuItems.forEachIndexed { i, item -> + item.alpha = if (i == index) 1.0f else 0.7f + item.scaleX = if (i == index) 1.2f else 1.0f + item.scaleY = if (i == index) 1.2f else 1.0f + // Add or remove border based on selection + if (item is Button) { + item.background = if (i == index) { + ContextCompat.getDrawable(this, R.drawable.menu_item_selected) + } else { + ColorDrawable(Color.TRANSPARENT) + } + } + } + } + + /** + * Move menu selection up + */ + private fun moveMenuSelectionUp() { + if (pauseMenuItems.isEmpty()) return + currentMenuSelection = (currentMenuSelection - 1 + pauseMenuItems.size) % pauseMenuItems.size + highlightMenuItem(currentMenuSelection) + scrollToSelectedItem() + gameHaptics.vibrateForPieceMove() + } + + /** + * Move menu selection down + */ + private fun moveMenuSelectionDown() { + if (pauseMenuItems.isEmpty()) return + currentMenuSelection = (currentMenuSelection + 1) % pauseMenuItems.size + highlightMenuItem(currentMenuSelection) + scrollToSelectedItem() + gameHaptics.vibrateForPieceMove() + } + + private fun scrollToSelectedItem() { + if (pauseMenuItems.isEmpty()) return + + val selectedItem = pauseMenuItems[currentMenuSelection] + val scrollView = pauseMenuScrollView ?: return + + // Calculate the item's position relative to the scroll view + val itemTop = selectedItem.top + val itemBottom = selectedItem.bottom + val scrollViewHeight = scrollView.height + + // If the item is partially or fully below the visible area, scroll down + if (itemBottom > scrollViewHeight) { + scrollView.smoothScrollTo(0, itemBottom - scrollViewHeight) + } + // If the item is partially or fully above the visible area, scroll up + else if (itemTop < 0) { + scrollView.smoothScrollTo(0, itemTop) + } + } + + /** + * Activate the currently selected menu item + */ + private fun activateSelectedMenuItem() { + if (pauseMenuItems.isEmpty() || currentMenuSelection < 0 || currentMenuSelection >= pauseMenuItems.size) return + pauseMenuItems[currentMenuSelection].performClick() + } } \ 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 index d6869c0..731ac8b 100644 --- a/app/src/main/java/com/mintris/game/GameHaptics.kt +++ b/app/src/main/java/com/mintris/game/GameHaptics.kt @@ -27,6 +27,19 @@ class GameHaptics(private val context: Context) { 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") @@ -34,22 +47,22 @@ class GameHaptics(private val context: Context) { // Only proceed if the device has a vibrator and it's available if (!vibrator.hasVibrator()) return - // Scale duration and amplitude based on line count - // More lines = longer and stronger vibration + // Scale duration and amplitude based on line count and input method + val multiplier = getVibrationMultiplier() val duration = when(lineCount) { - 1 -> 50L // Single line: short vibration - 2 -> 80L // Double line: slightly longer - 3 -> 120L // Triple line: even longer - 4 -> 200L // Tetris: longest vibration - else -> 50L + 1 -> (50L * multiplier).toLong() // Single line: short vibration + 2 -> (80L * multiplier).toLong() // Double line: slightly longer + 3 -> (120L * multiplier).toLong() // Triple line: even longer + 4 -> (200L * multiplier).toLong() // Tetris: longest vibration + else -> (50L * multiplier).toLong() } val amplitude = when(lineCount) { - 1 -> 80 // Single line: mild vibration (80/255) - 2 -> 120 // Double line: medium vibration (120/255) - 3 -> 180 // Triple line: strong vibration (180/255) - 4 -> 255 // Tetris: maximum vibration (255/255) - else -> 80 + 1 -> (80 * multiplier).toInt().coerceAtMost(255) // Single line: mild vibration + 2 -> (120 * multiplier).toInt().coerceAtMost(255) // Double line: medium vibration + 3 -> (180 * multiplier).toInt().coerceAtMost(255) // Triple line: strong vibration + 4 -> 255 // Tetris: maximum vibration + else -> (80 * multiplier).toInt().coerceAtMost(255) } Log.d(TAG, "Vibration parameters - Duration: ${duration}ms, Amplitude: $amplitude") @@ -79,15 +92,20 @@ class GameHaptics(private val context: Context) { fun vibrateForPieceLock() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val vibrationEffect = VibrationEffect.createOneShot(50L, VibrationEffect.DEFAULT_AMPLITUDE) + 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 amplitude = (VibrationEffect.DEFAULT_AMPLITUDE * 0.3).toInt().coerceAtLeast(1) - val vibrationEffect = VibrationEffect.createOneShot(20L, amplitude) + 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) } } @@ -100,8 +118,10 @@ class GameHaptics(private val context: Context) { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a strong, long vibration effect - val vibrationEffect = VibrationEffect.createOneShot(300L, VibrationEffect.DEFAULT_AMPLITUDE) + 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 { diff --git a/app/src/main/java/com/mintris/game/GameView.kt b/app/src/main/java/com/mintris/game/GameView.kt index f5043f1..67bb35a 100644 --- a/app/src/main/java/com/mintris/game/GameView.kt +++ b/app/src/main/java/com/mintris/game/GameView.kt @@ -1019,16 +1019,6 @@ class GameView @JvmOverloads constructor( return true } - // Define the game board boundaries - val boardRight = boardLeft + (gameBoard.width * blockSize) - val boardBottom = boardTop + (gameBoard.height * blockSize) - - // Determine if touch is on left side, right side, or within board area - val isLeftSide = event.x < boardLeft - val isRightSide = event.x > boardRight - val isWithinBoard = event.x >= boardLeft && event.x <= boardRight && - event.y >= boardTop && event.y <= boardBottom - when (event.action) { MotionEvent.ACTION_DOWN -> { startX = event.x @@ -1061,81 +1051,36 @@ class GameView @JvmOverloads constructor( } } - // Special handling for landscape mode - side controls - if (isLeftSide) { - // Left side controls - move left - if (deltaY < -blockSize * minMovementThreshold) { - // Swipe up on left side - rotate - if (currentTime - lastRotationTime >= rotationCooldown) { - gameBoard.rotate() - lastRotationTime = currentTime - gameHaptics?.vibrateForPieceMove() + // 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() } - } else if (deltaY > blockSize * minMovementThreshold) { - // Swipe down on left side - move left - gameBoard.moveLeft() - lastTouchY = event.y - if (currentTime - lastMoveTime >= moveCooldown) { - gameHaptics?.vibrateForPieceMove() - lastMoveTime = currentTime - } - invalidate() } - } else if (isRightSide) { - // Right side controls - move right - if (deltaY < -blockSize * minMovementThreshold) { - // Swipe up on right side - hold piece - if (currentTime - lastHoldTime >= holdCooldown) { - gameBoard.holdPiece() - lastHoldTime = currentTime - gameHaptics?.vibrateForPieceMove() + Direction.VERTICAL -> { + if (deltaY > blockSize * minMovementThreshold) { + gameBoard.softDrop() + lastTouchY = event.y + if (currentTime - lastMoveTime >= moveCooldown) { + gameHaptics?.vibrateForPieceMove() + lastMoveTime = currentTime + } invalidate() } - } else if (deltaY > blockSize * minMovementThreshold) { - // Swipe down on right side - move right - gameBoard.moveRight() - lastTouchY = event.y - if (currentTime - lastMoveTime >= moveCooldown) { - gameHaptics?.vibrateForPieceMove() - lastMoveTime = currentTime - } - invalidate() } - } - // Standard touch controls for main board area or portrait mode - else { - // Handle movement based on locked direction - when (lockedDirection) { - Direction.HORIZONTAL -> { - if (abs(deltaX) > blockSize * minMovementThreshold) { - if (deltaX > 0) { - gameBoard.moveRight() - } else { - gameBoard.moveLeft() - } - lastTouchX = event.x - if (currentTime - lastMoveTime >= moveCooldown) { - gameHaptics?.vibrateForPieceMove() - lastMoveTime = currentTime - } - invalidate() - } - } - Direction.VERTICAL -> { - if (deltaY > blockSize * minMovementThreshold) { - gameBoard.softDrop() - lastTouchY = event.y - if (currentTime - lastMoveTime >= moveCooldown) { - gameHaptics?.vibrateForPieceMove() - lastMoveTime = currentTime - } - invalidate() - } - } - null -> { - // No direction lock yet, don't process movement - } + null -> { + // No direction lock yet, don't process movement } } } @@ -1145,23 +1090,12 @@ class GameView @JvmOverloads constructor( val deltaY = event.y - startY val moveTime = currentTime - lastTapTime - // Special handling for taps on game board or sides + // Handle taps for rotation if (moveTime < minTapTime * 1.5 && abs(deltaY) < maxTapMovement * 1.5 && abs(deltaX) < maxTapMovement * 1.5) { - if (isLeftSide) { - // Tap on left side - move left - gameBoard.moveLeft() - gameHaptics?.vibrateForPieceMove() - invalidate() - } else if (isRightSide) { - // Tap on right side - move right - gameBoard.moveRight() - gameHaptics?.vibrateForPieceMove() - invalidate() - } else if (isWithinBoard && currentTime - lastRotationTime >= rotationCooldown) { - // Tap on board - rotate + if (currentTime - lastRotationTime >= rotationCooldown) { gameBoard.rotate() lastRotationTime = currentTime gameHaptics?.vibrateForPieceMove() @@ -1170,15 +1104,11 @@ class GameView @JvmOverloads constructor( return true } - // Long swipe handling for hard drops and holds + // Handle gestures // Check for hold gesture (swipe up) if (deltaY < -blockSize * minHoldDistance && abs(deltaX) / abs(deltaY) < 0.5f) { - if (currentTime - lastHoldTime < holdCooldown) { - Log.d(TAG, "Hold blocked by cooldown - time since last: ${currentTime - lastHoldTime}ms, cooldown: ${holdCooldown}ms") - } else { - // Process the hold - Log.d(TAG, "Hold detected - deltaY: $deltaY, ratio: ${abs(deltaX) / abs(deltaY)}") + if (currentTime - lastHoldTime >= holdCooldown) { gameBoard.holdPiece() lastHoldTime = currentTime gameHaptics?.vibrateForPieceMove() @@ -1189,11 +1119,7 @@ class GameView @JvmOverloads constructor( else if (deltaY > blockSize * minHardDropDistance && abs(deltaX) / abs(deltaY) < 0.5f && (deltaY / moveTime) * 1000 > minSwipeVelocity) { - if (currentTime - lastHardDropTime < hardDropCooldown) { - Log.d(TAG, "Hard drop blocked by cooldown - time since last: ${currentTime - lastHardDropTime}ms, cooldown: ${hardDropCooldown}ms") - } else { - // Process the hard drop - Log.d(TAG, "Hard drop detected - deltaY: $deltaY, velocity: ${(deltaY / moveTime) * 1000}, ratio: ${abs(deltaX) / abs(deltaY)}") + if (currentTime - lastHardDropTime >= hardDropCooldown) { gameBoard.hardDrop() lastHardDropTime = currentTime invalidate() @@ -1487,4 +1413,71 @@ class GameView @JvmOverloads constructor( 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() + } + + /** + * 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() + } } diff --git a/app/src/main/java/com/mintris/game/GamepadController.kt b/app/src/main/java/com/mintris/game/GamepadController.kt new file mode 100644 index 0000000..811dea9 --- /dev/null +++ b/app/src/main/java/com/mintris/game/GamepadController.kt @@ -0,0 +1,495 @@ +package com.mintris.game + +import android.os.SystemClock +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.util.Log +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.view.InputDevice.MotionRange +import android.os.Vibrator +import android.os.Handler +import android.os.Looper + +/** + * GamepadController handles gamepad input for the Mintris game. + * Supports multiple gamepad types including: + * - Microsoft Xbox controllers + * - Sony PlayStation controllers + * - Nintendo Switch controllers + * - Backbone controllers + */ +class GamepadController( + private val gameView: GameView +) { + companion object { + private const val TAG = "GamepadController" + + // Deadzone for analog sticks (normalized value 0.0-1.0) + private const val STICK_DEADZONE = 0.30f + + // Cooldown times for responsive input without repeating too quickly + private const val MOVE_COOLDOWN_MS = 100L + private const val ROTATION_COOLDOWN_MS = 150L + private const val HARD_DROP_COOLDOWN_MS = 200L + private const val HOLD_COOLDOWN_MS = 250L + + // Continuous movement repeat delay + private const val CONTINUOUS_MOVEMENT_DELAY_MS = 100L + + // Rumble patterns + private const val RUMBLE_MOVE_DURATION_MS = 20L + private const val RUMBLE_ROTATE_DURATION_MS = 30L + private const val RUMBLE_HARD_DROP_DURATION_MS = 100L + private const val RUMBLE_LINE_CLEAR_DURATION_MS = 150L + + // Check if device is a gamepad + fun isGamepad(device: InputDevice?): Boolean { + if (device == null) return false + + // Check for gamepad via input device sources + val sources = device.sources + return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + } + + // Get a list of all connected gamepads + fun getGamepads(): List { + 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() + } + + // Listeners + private var connectionListener: GamepadConnectionListener? = null + private var menuListener: GamepadMenuListener? = null + private var navigationListener: GamepadNavigationListener? = null + + // Currently active gamepad for rumble + private var activeGamepad: InputDevice? = null + + /** + * Set a listener for gamepad connection events + */ + fun setGamepadConnectionListener(listener: GamepadConnectionListener) { + connectionListener = listener + } + + /** + * Set a listener for gamepad menu events (pause/start button) + */ + fun setGamepadMenuListener(listener: GamepadMenuListener) { + menuListener = listener + } + + /** + * Set a listener for gamepad navigation events + */ + fun setGamepadNavigationListener(listener: GamepadNavigationListener) { + navigationListener = listener + } + + /** + * Function to check for newly connected gamepads. + * Call this periodically from the activity to detect connection changes. + */ + fun checkForGamepadChanges(context: Context) { + // Implementation would track previous and current gamepads + // and notify through the connectionListener + // This would be called from the activity's onResume or via a handler + } + + /** + * Vibrate the gamepad if supported + */ + fun vibrateGamepad(durationMs: Long, amplitude: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val gamepad = activeGamepad ?: return + + if (supportsVibration(gamepad)) { + try { + val vibrator = gamepad.vibratorManager + val vibratorIds = vibrator.vibratorIds + + if (vibratorIds.isNotEmpty()) { + val effect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + VibrationEffect.createOneShot(durationMs, amplitude) + } else { + // For older devices, fall back to a simple vibration + VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE) + } + + // Create combined vibration for Android S+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val combinedVibration = android.os.CombinedVibration.createParallel(effect) + vibrator.vibrate(combinedVibration) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error vibrating gamepad", e) + } + } + } + + /** + * Vibrate for piece movement + */ + fun vibrateForPieceMove() { + vibrateGamepad(RUMBLE_MOVE_DURATION_MS, 50) + } + + /** + * Vibrate for piece rotation + */ + fun vibrateForPieceRotation() { + vibrateGamepad(RUMBLE_ROTATE_DURATION_MS, 80) + } + + /** + * Vibrate for hard drop + */ + fun vibrateForHardDrop() { + vibrateGamepad(RUMBLE_HARD_DROP_DURATION_MS, 150) + } + + /** + * Vibrate for line clear + */ + fun vibrateForLineClear(lineCount: Int) { + val amplitude = when (lineCount) { + 1 -> 100 + 2 -> 150 + 3 -> 200 + else -> 255 // For tetris (4 lines) + } + vibrateGamepad(RUMBLE_LINE_CLEAR_DURATION_MS, amplitude) + } + + /** + * Process a key event from a gamepad + * @return true if the event was handled, false otherwise + */ + fun handleKeyEvent(keyCode: Int, event: KeyEvent): Boolean { + // Skip if game is not active but handle menu navigation + if (!gameView.isActive()) { + // Handle menu navigation even when game is not active + if (event.action == KeyEvent.ACTION_DOWN) { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + navigationListener?.onMenuUp() + return true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + navigationListener?.onMenuDown() + return true + } + KeyEvent.KEYCODE_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, + KeyEvent.KEYCODE_BUTTON_B -> { + if (currentTime - lastRotationTime > ROTATION_COOLDOWN_MS) { + gameView.rotate() + vibrateForPieceRotation() + lastRotationTime = currentTime + return true + } + } + // Hold piece buttons + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BUTTON_L1, + KeyEvent.KEYCODE_BUTTON_R1 -> { + if (currentTime - lastHoldTime > HOLD_COOLDOWN_MS) { + gameView.holdPiece() + vibrateForPieceRotation() + lastHoldTime = currentTime + return true + } + } + } + } + KeyEvent.ACTION_UP -> { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + isMovingLeft = false + handler.removeCallbacks(moveLeftRunnable) + return true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + isMovingRight = false + handler.removeCallbacks(moveRightRunnable) + return true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + isMovingDown = false + handler.removeCallbacks(moveDownRunnable) + return true + } + } + } + } + + return false + } + + /** + * Process generic motion events (for analog sticks) + * @return true if the event was handled, false otherwise + */ + fun handleMotionEvent(event: MotionEvent): Boolean { + // Skip if game is not active + if (!gameView.isActive()) return false + + val device = event.device + if (!isGamepad(device)) return false + + // Update active gamepad for rumble + activeGamepad = device + + val currentTime = SystemClock.uptimeMillis() + + // Process left analog stick + val axisX = event.getAxisValue(MotionEvent.AXIS_X) + val axisY = event.getAxisValue(MotionEvent.AXIS_Y) + + // Apply deadzone + if (Math.abs(axisX) > STICK_DEADZONE) { + if (axisX > 0 && !isMovingRight) { + gameView.moveRight() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingRight = true + isMovingLeft = false + + // Start continuous movement after initial input + handler.removeCallbacks(moveLeftRunnable) + handler.postDelayed(moveRightRunnable, CONTINUOUS_MOVEMENT_DELAY_MS) + return true + } else if (axisX < 0 && !isMovingLeft) { + gameView.moveLeft() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingLeft = true + isMovingRight = false + + // Start continuous movement after initial input + handler.removeCallbacks(moveRightRunnable) + handler.postDelayed(moveLeftRunnable, CONTINUOUS_MOVEMENT_DELAY_MS) + return true + } + } else { + // Reset horizontal movement flags when stick returns to center + isMovingLeft = false + isMovingRight = false + handler.removeCallbacks(moveLeftRunnable) + handler.removeCallbacks(moveRightRunnable) + } + + if (Math.abs(axisY) > STICK_DEADZONE) { + if (axisY > 0 && !isMovingDown) { + gameView.softDrop() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingDown = true + + // Start continuous movement after initial input + handler.postDelayed(moveDownRunnable, CONTINUOUS_MOVEMENT_DELAY_MS) + return true + } + } else { + // Reset vertical movement flag when stick returns to center + isMovingDown = false + handler.removeCallbacks(moveDownRunnable) + } + + // Check right analog stick for rotation + val axisZ = event.getAxisValue(MotionEvent.AXIS_Z) + val axisRZ = event.getAxisValue(MotionEvent.AXIS_RZ) + + if (Math.abs(axisZ) > STICK_DEADZONE || Math.abs(axisRZ) > STICK_DEADZONE) { + if (currentTime - lastRotationTime > ROTATION_COOLDOWN_MS) { + gameView.rotate() + vibrateForPieceRotation() + lastRotationTime = currentTime + return true + } + } + + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/TitleScreen.kt b/app/src/main/java/com/mintris/game/TitleScreen.kt index 34627f7..324f81b 100644 --- a/app/src/main/java/com/mintris/game/TitleScreen.kt +++ b/app/src/main/java/com/mintris/game/TitleScreen.kt @@ -245,6 +245,7 @@ class TitleScreen @JvmOverloads constructor( // Draw high scores using pre-allocated manager val highScores: List = highScoreManager.getHighScores() val highScoreY = height * 0.5f + var lastHighScoreY = highScoreY if (highScores.isNotEmpty()) { // Calculate the starting X position to center the entire block of scores val maxScoreWidth = highScorePaint.measureText("99. PLAYER: 999999") @@ -252,6 +253,7 @@ class TitleScreen @JvmOverloads constructor( highScores.forEachIndexed { index: Int, score: HighScore -> val y = highScoreY + (index * 80f) + lastHighScoreY = y // Track the last high score's Y position // Pad the rank number to ensure alignment val rank = (index + 1).toString().padStart(2, ' ') // Pad the name to ensure score alignment @@ -260,8 +262,15 @@ class TitleScreen @JvmOverloads constructor( } } - // Draw "touch to start" prompt - canvas.drawText("touch to start", width / 2f, height * 0.7f, promptPaint) + // Draw "touch to start" prompt below the high scores + val promptY = if (resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) { + // In landscape mode, position below the last high score with some padding + lastHighScoreY + 100f + } else { + // In portrait mode, use the original position + height * 0.7f + } + canvas.drawText("touch to start", width / 2f, promptY, promptPaint) // Request another frame invalidate() diff --git a/app/src/main/java/com/mintris/ui/ProgressionScreen.kt b/app/src/main/java/com/mintris/ui/ProgressionScreen.kt index 8c5d4d4..117a440 100644 --- a/app/src/main/java/com/mintris/ui/ProgressionScreen.kt +++ b/app/src/main/java/com/mintris/ui/ProgressionScreen.kt @@ -304,4 +304,11 @@ class ProgressionScreen @JvmOverloads constructor( card?.setCardBackgroundColor(backgroundColor) } } + + /** + * Public method to handle continue action via gamepad + */ + fun performContinue() { + continueButton.performClick() + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 0000000..1280712 --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/menu_item_selected.xml b/app/src/main/res/drawable/menu_item_selected.xml new file mode 100644 index 0000000..3beb40d --- /dev/null +++ b/app/src/main/res/drawable/menu_item_selected.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 20ad154..7630ab8 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -8,16 +8,24 @@ android:fitsSystemWindows="true" tools:context=".MainActivity"> + + + + android:background="@drawable/glow_border" + android:clickable="false" + android:focusable="false" /> - + + app:layout_constraintWidth_percent="0.25" + android:clickable="false" + android:focusable="false" + android:elevation="1dp"> - + + app:layout_constraintWidth_percent="0.25" + android:clickable="false" + android:focusable="false" + android:elevation="1dp"> + android:layout_height="match_parent" + android:scrollbars="none" + android:overScrollMode="never" + android:fillViewport="true"> + + android:gravity="center" + android:layout_marginTop="16dp"> + android:fontFamily="sans-serif" />