mirror of
https://github.com/cmclark00/mintris.git
synced 2025-05-18 15:25:20 +01:00
adding gamepad support
This commit is contained in:
parent
86424eac32
commit
0ac25eb3a9
5 changed files with 796 additions and 5 deletions
|
@ -31,8 +31,15 @@ import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
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 {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
|
@ -66,6 +73,24 @@ class MainActivity : AppCompatActivity() {
|
||||||
// Activity result launcher for high score entry
|
// Activity result launcher for high score entry
|
||||||
private lateinit var highScoreEntryLauncher: ActivityResultLauncher<Intent>
|
private lateinit var highScoreEntryLauncher: ActivityResultLauncher<Intent>
|
||||||
|
|
||||||
|
// 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<String>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// Register activity result launcher for high score entry
|
// Register activity result launcher for high score entry
|
||||||
highScoreEntryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
highScoreEntryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
@ -93,6 +118,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
themeSelector = binding.themeSelector
|
themeSelector = binding.themeSelector
|
||||||
blockSkinSelector = binding.blockSkinSelector
|
blockSkinSelector = binding.blockSkinSelector
|
||||||
|
|
||||||
|
// Initialize gamepad controller
|
||||||
|
gamepadController = GamepadController(gameView)
|
||||||
|
gamepadController.setGamepadConnectionListener(this)
|
||||||
|
|
||||||
// Set up touch event forwarding
|
// Set up touch event forwarding
|
||||||
binding.touchInterceptor?.setOnTouchListener { _, event ->
|
binding.touchInterceptor?.setOnTouchListener { _, event ->
|
||||||
if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) {
|
if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) {
|
||||||
|
@ -231,8 +260,35 @@ class MainActivity : AppCompatActivity() {
|
||||||
updateUI(score, level, lines)
|
updateUI(score, level, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
gameView.onGameOver = { score ->
|
gameView.onGameOver = { finalScore ->
|
||||||
showGameOver(score)
|
// 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 ->
|
gameView.onLineClear = { lineCount ->
|
||||||
|
@ -577,12 +633,83 @@ class MainActivity : AppCompatActivity() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new game
|
||||||
|
*/
|
||||||
private fun startGame() {
|
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()
|
gameView.start()
|
||||||
gameMusic.setEnabled(isMusicEnabled)
|
gameMusic.setEnabled(isMusicEnabled)
|
||||||
|
|
||||||
|
// Start background music if enabled
|
||||||
if (isMusicEnabled) {
|
if (isMusicEnabled) {
|
||||||
gameMusic.start()
|
gameMusic.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStartTime = System.currentTimeMillis()
|
gameStartTime = System.currentTimeMillis()
|
||||||
piecesPlaced = 0
|
piecesPlaced = 0
|
||||||
statsManager.startNewSession()
|
statsManager.startNewSession()
|
||||||
|
@ -612,10 +739,25 @@ class MainActivity : AppCompatActivity() {
|
||||||
gameView.pause()
|
gameView.pause()
|
||||||
gameMusic.pause()
|
gameMusic.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unregister broadcast receiver
|
||||||
|
try {
|
||||||
|
unregisterReceiver(inputDeviceReceiver)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// Receiver wasn't registered
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.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 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) {
|
if (titleScreen.visibility == View.GONE && gameView.visibility == View.VISIBLE && binding.gameOverContainer.visibility == View.GONE && binding.pauseContainer.visibility == View.GONE) {
|
||||||
resumeGame()
|
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<View>(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 {
|
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) {
|
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||||
// Handle back button press as a pause action during gameplay
|
// Handle back button press as a pause action during gameplay
|
||||||
if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) {
|
if (gameView.visibility == View.VISIBLE && !gameView.isPaused && !gameView.isGameOver()) {
|
||||||
|
@ -807,9 +1038,24 @@ class MainActivity : AppCompatActivity() {
|
||||||
return true // Consume the event
|
return true // Consume the event
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.onKeyDown(keyCode, 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<String>) {
|
private fun showProgressionScreen(xpGained: Long, newRewards: List<String>) {
|
||||||
// Apply theme before showing the screen
|
// Apply theme before showing the screen
|
||||||
progressionScreen.applyTheme(currentTheme)
|
progressionScreen.applyTheme(currentTheme)
|
||||||
|
|
|
@ -1413,4 +1413,71 @@ class GameView @JvmOverloads constructor(
|
||||||
gameOverBlocksSpeed[i] *= 1.03f
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
372
app/src/main/java/com/mintris/game/GamepadController.kt
Normal file
372
app/src/main/java/com/mintris/game/GamepadController.kt
Normal file
|
@ -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<InputDevice> {
|
||||||
|
val gamepads = mutableListOf<InputDevice>()
|
||||||
|
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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
7
app/src/main/res/drawable/dialog_background.xml
Normal file
7
app/src/main/res/drawable/dialog_background.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#AA000000" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
<stroke android:width="2dp" android:color="#FFFFFF" />
|
||||||
|
</shape>
|
99
app/src/main/res/layout/gamepad_help_dialog.xml
Normal file
99
app/src/main/res/layout/gamepad_help_dialog.xml
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:background="@drawable/dialog_background">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Gamepad Controls"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:gravity="center"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Movement"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:layout_marginBottom="8dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="D-Pad Left/Right or Left Stick: Move piece"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:layout_marginBottom="4dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="D-Pad Down or Left Stick Down: Soft drop"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:layout_marginBottom="4dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="D-Pad Up: Hard drop"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FFFFFF"/>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Actions"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:layout_marginBottom="8dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="A/B/X or Right Stick: Rotate piece"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:layout_marginBottom="4dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Y, L1, or R1: Hold piece"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FFFFFF"/>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/gamepad_help_dismiss_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Got it!"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:paddingLeft="24dp"
|
||||||
|
android:paddingRight="24dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
Loading…
Add table
Add a link
Reference in a new issue