From 86424eac32196b888293224563847290c9caaf4f Mon Sep 17 00:00:00 2001 From: Corey Date: Mon, 31 Mar 2025 04:05:50 -0400 Subject: [PATCH 1/9] Update game layout and functionality in MainActivity and GameView --- app/src/main/java/com/mintris/MainActivity.kt | 10 ++ .../main/java/com/mintris/game/GameView.kt | 132 ++++-------------- .../main/res/layout-land/activity_main.xml | 32 +++-- 3 files changed, 63 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/com/mintris/MainActivity.kt b/app/src/main/java/com/mintris/MainActivity.kt index 7aa7bb9..253f8b9 100644 --- a/app/src/main/java/com/mintris/MainActivity.kt +++ b/app/src/main/java/com/mintris/MainActivity.kt @@ -93,6 +93,16 @@ class MainActivity : AppCompatActivity() { themeSelector = binding.themeSelector blockSkinSelector = binding.blockSkinSelector + // 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 diff --git a/app/src/main/java/com/mintris/game/GameView.kt b/app/src/main/java/com/mintris/game/GameView.kt index f5043f1..05308d8 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() diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 20ad154..0e03b36 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"> Date: Mon, 31 Mar 2025 04:52:11 -0400 Subject: [PATCH 2/9] adding gamepad support --- app/src/main/java/com/mintris/MainActivity.kt | 256 +++++++++++- .../main/java/com/mintris/game/GameView.kt | 67 ++++ .../com/mintris/game/GamepadController.kt | 372 ++++++++++++++++++ .../main/res/drawable/dialog_background.xml | 7 + .../main/res/layout/gamepad_help_dialog.xml | 99 +++++ 5 files changed, 796 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/mintris/game/GamepadController.kt create mode 100644 app/src/main/res/drawable/dialog_background.xml create mode 100644 app/src/main/res/layout/gamepad_help_dialog.xml 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +