diff --git a/app/src/main/java/com/mintris/MainActivity.kt b/app/src/main/java/com/mintris/MainActivity.kt index 253f8b9..350a8f2 100644 --- a/app/src/main/java/com/mintris/MainActivity.kt +++ b/app/src/main/java/com/mintris/MainActivity.kt @@ -31,8 +31,15 @@ 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 -class MainActivity : AppCompatActivity() { +class MainActivity : AppCompatActivity(), GamepadController.GamepadConnectionListener { companion object { private const val TAG = "MainActivity" @@ -65,6 +72,24 @@ 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() override fun onCreate(savedInstanceState: Bundle?) { // Register activity result launcher for high score entry @@ -93,6 +118,10 @@ class MainActivity : AppCompatActivity() { themeSelector = binding.themeSelector blockSkinSelector = binding.blockSkinSelector + // Initialize gamepad controller + gamepadController = GamepadController(gameView) + gamepadController.setGamepadConnectionListener(this) + // Set up touch event forwarding binding.touchInterceptor?.setOnTouchListener { _, event -> if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) { @@ -231,8 +260,35 @@ class MainActivity : AppCompatActivity() { updateUI(score, level, lines) } - gameView.onGameOver = { score -> - showGameOver(score) + 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 -> @@ -577,12 +633,83 @@ class MainActivity : AppCompatActivity() { ) } + /** + * Start a new game + */ private fun startGame() { + // Set initial game state + currentScore = 0 + currentLevel = selectedLevel + piecesPlaced = 0 + gameStartTime = System.currentTimeMillis() + + // Update UI to show initial values + binding.scoreText.text = "$currentScore" + binding.currentLevelText.text = "$currentLevel" + binding.linesText.text = "0" + + // Reset game view and game board + gameView.reset() + + // Configure callbacks + gameView.onGameStateChanged = { score, level, lines -> + currentScore = score + currentLevel = level + + binding.scoreText.text = "$score" + binding.currentLevelText.text = "$level" + binding.linesText.text = "$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(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) + } + + // Start the game gameView.start() gameMusic.setEnabled(isMusicEnabled) + + // Start background music if enabled if (isMusicEnabled) { gameMusic.start() } + gameStartTime = System.currentTimeMillis() piecesPlaced = 0 statsManager.startNewSession() @@ -612,10 +739,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() @@ -783,10 +925,99 @@ 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() + // 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() + } + } + + /** + * 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)) { + // 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()) { @@ -807,9 +1038,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) diff --git a/app/src/main/java/com/mintris/game/GameView.kt b/app/src/main/java/com/mintris/game/GameView.kt index 05308d8..67bb35a 100644 --- a/app/src/main/java/com/mintris/game/GameView.kt +++ b/app/src/main/java/com/mintris/game/GameView.kt @@ -1413,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..5e81d60 --- /dev/null +++ b/app/src/main/java/com/mintris/game/GamepadController.kt @@ -0,0 +1,372 @@ +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 + +/** + * 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.25f + + // 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 + + // 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 + + // Callback interfaces + interface GamepadConnectionListener { + fun onGamepadConnected(gamepadName: String) + fun onGamepadDisconnected(gamepadName: String) + } + + // Listener for gamepad connection + private var connectionListener: GamepadConnectionListener? = null + + // Currently active gamepad for rumble + private var activeGamepad: InputDevice? = null + + /** + * Set a listener for gamepad connection events + */ + fun setGamepadConnectionListener(listener: GamepadConnectionListener) { + connectionListener = 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 + 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() + + when (event.action) { + KeyEvent.ACTION_DOWN -> { + when (keyCode) { + // D-pad and analog movement + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (!isMovingLeft && currentTime - lastMoveTime > MOVE_COOLDOWN_MS) { + gameView.moveLeft() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingLeft = true + return true + } + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isMovingRight && currentTime - lastMoveTime > MOVE_COOLDOWN_MS) { + gameView.moveRight() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingRight = true + return true + } + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + if (!isMovingDown && currentTime - lastMoveTime > MOVE_COOLDOWN_MS) { + gameView.softDrop() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingDown = true + return true + } + } + KeyEvent.KEYCODE_DPAD_UP -> { + if (currentTime - lastHardDropTime > HARD_DROP_COOLDOWN_MS) { + gameView.hardDrop() + vibrateForHardDrop() + lastHardDropTime = currentTime + 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 + return true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + isMovingRight = false + return true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + isMovingDown = false + 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 && currentTime - lastMoveTime > MOVE_COOLDOWN_MS) { + gameView.moveRight() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingRight = true + isMovingLeft = false + return true + } else if (axisX < 0 && !isMovingLeft && currentTime - lastMoveTime > MOVE_COOLDOWN_MS) { + gameView.moveLeft() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingLeft = true + isMovingRight = false + return true + } + } else { + // Reset horizontal movement flags when stick returns to center + isMovingLeft = false + isMovingRight = false + } + + if (Math.abs(axisY) > STICK_DEADZONE) { + if (axisY > 0 && !isMovingDown && currentTime - lastMoveTime > MOVE_COOLDOWN_MS) { + gameView.softDrop() + vibrateForPieceMove() + lastMoveTime = currentTime + isMovingDown = true + return true + } + } else { + // Reset vertical movement flag when stick returns to center + isMovingDown = false + } + + // 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/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/layout/gamepad_help_dialog.xml b/app/src/main/res/layout/gamepad_help_dialog.xml new file mode 100644 index 0000000..89383a7 --- /dev/null +++ b/app/src/main/res/layout/gamepad_help_dialog.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +