Compare commits

...

9 commits

12 changed files with 1407 additions and 201 deletions

View file

@ -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

View file

@ -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
}
}

View file

@ -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<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>()
// Track currently selected menu item in pause menu for gamepad navigation
private var currentMenuSelection = 0
private val pauseMenuItems = mutableListOf<View>()
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<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 {
// 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<String>) {
// 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()
}
}

View file

@ -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 {

View file

@ -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()
}
}

View file

@ -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<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
// 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
}
}

View file

@ -245,6 +245,7 @@ class TitleScreen @JvmOverloads constructor(
// Draw high scores using pre-allocated manager
val highScores: List<HighScore> = 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()

View file

@ -304,4 +304,11 @@ class ProgressionScreen @JvmOverloads constructor(
card?.setCardBackgroundColor(backgroundColor)
}
}
/**
* Public method to handle continue action via gamepad
*/
fun performContinue() {
continueButton.performClick()
}
}

View 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>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="2dp"
android:color="@color/white" />
<corners android:radius="8dp" />
<solid android:color="@android:color/transparent" />
</shape>

View file

@ -8,16 +8,24 @@
android:fitsSystemWindows="true"
tools:context=".MainActivity">
<!-- Full Screen Touch Interceptor -->
<View
android:id="@+id/touchInterceptor"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true" />
<!-- Game Container with Glow - Centered -->
<FrameLayout
android:id="@+id/gameContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="12dp"
android:layout_margin="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:2"
app:layout_constraintEnd_toStartOf="@+id/rightControlsPanel"
app:layout_constraintStart_toEndOf="@+id/leftControlsPanel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.mintris.game.GameView
@ -30,10 +38,12 @@
android:id="@+id/glowBorder"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/glow_border" />
android:background="@drawable/glow_border"
android:clickable="false"
android:focusable="false" />
</FrameLayout>
<!-- Left Side Controls Panel -->
<!-- Left Side Controls Panel - Overlay -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/leftControlsPanel"
android:layout_width="0dp"
@ -42,7 +52,10 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.25">
app:layout_constraintWidth_percent="0.25"
android:clickable="false"
android:focusable="false"
android:elevation="1dp">
<!-- Hold Piece View -->
<TextView
@ -83,7 +96,7 @@
android:layout_marginBottom="24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Right Side Controls Panel -->
<!-- Right Side Controls Panel - Overlay -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/rightControlsPanel"
android:layout_width="0dp"
@ -92,7 +105,10 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.25">
app:layout_constraintWidth_percent="0.25"
android:clickable="false"
android:focusable="false"
android:elevation="1dp">
<!-- Next Piece Preview -->
<TextView
@ -226,8 +242,12 @@
<!-- Scrollable content for pause menu -->
<ScrollView
android:id="@+id/pauseMenuScrollView"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:scrollbars="none"
android:overScrollMode="never"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
@ -300,13 +320,13 @@
android:fontFamily="sans-serif"
android:textAllCaps="false" />
<!-- Level Selection -->
<LinearLayout
android:id="@+id/levelSelectorContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:orientation="vertical"
android:gravity="center">
android:gravity="center"
android:layout_marginTop="16dp">
<TextView
android:id="@+id/selectLevelText"
@ -314,15 +334,15 @@
android:layout_height="wrap_content"
android:text="@string/select_level"
android:textColor="@color/white"
android:textSize="24sp"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="sans-serif"
android:textAllCaps="false" />
android:fontFamily="sans-serif" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="8dp">
<Button
@ -330,7 +350,7 @@
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@color/transparent"
android:text=""
android:text="-"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
@ -338,14 +358,13 @@
<TextView
android:id="@+id/pauseLevelText"
android:layout_width="48dp"
android:layout_height="48dp"
android:gravity="center"
android:text="1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="sans-serif" />
android:fontFamily="sans-serif"
android:layout_marginHorizontal="16dp" />
<Button
android:id="@+id/pauseLevelUpButton"
@ -367,15 +386,44 @@
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<!-- Block Skin Selector -->
<com.mintris.ui.BlockSkinSelector
android:id="@+id/inPauseBlockSkinSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" />
<!-- Sound Settings -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="16dp">
<TextView
android:id="@+id/musicText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music"
android:textColor="@color/white"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="sans-serif" />
<ImageButton
android:id="@+id/musicToggle"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:background="@color/transparent"
android:contentDescription="@string/toggle_music"
android:src="@drawable/ic_volume_up" />
</LinearLayout>
<!-- Sound Toggle -->
<Button
android:id="@+id/settingsButton"
android:layout_width="200dp"
@ -388,35 +436,6 @@
android:textStyle="bold"
android:fontFamily="sans-serif"
android:textAllCaps="false" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp">
<TextView
android:id="@+id/musicText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="sans-serif"
android:textAllCaps="false"
android:layout_marginEnd="16dp" />
<ImageButton
android:id="@+id/musicToggle"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/toggle_music"
android:padding="12dp"
android:src="@drawable/ic_volume_up" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>

View 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>