more gamepad support

This commit is contained in:
cmclark00 2025-03-31 13:38:38 -04:00
parent 7e4423efce
commit 7d7090d7ea
5 changed files with 818 additions and 254 deletions

View file

@ -43,6 +43,8 @@ import androidx.core.content.ContextCompat
import android.widget.Button import android.widget.Button
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.ImageButton import android.widget.ImageButton
import android.graphics.drawable.GradientDrawable
import android.widget.TextView
class MainActivity : AppCompatActivity(), class MainActivity : AppCompatActivity(),
GamepadController.GamepadConnectionListener, GamepadController.GamepadConnectionListener,
@ -552,18 +554,123 @@ class MainActivity : AppCompatActivity(),
*/ */
private fun showPauseMenu() { private fun showPauseMenu() {
binding.pauseContainer.visibility = View.VISIBLE binding.pauseContainer.visibility = View.VISIBLE
binding.pauseStartButton.visibility = View.VISIBLE
binding.resumeButton.visibility = View.GONE // Set button visibility based on game state
if (gameView.isPaused) {
binding.resumeButton.visibility = View.VISIBLE
binding.pauseStartButton.visibility = View.GONE
} else {
// This case might happen if pause is triggered before game start (e.g., from title)
binding.resumeButton.visibility = View.GONE
binding.pauseStartButton.visibility = View.VISIBLE
}
// Check if we're in landscape mode (used for finding specific views, not for order)
val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
// Update level badge // Update level badge
binding.pauseLevelBadge?.setLevel(progressionManager.getPlayerLevel()) binding.pauseLevelBadge?.setLevel(progressionManager.getPlayerLevel())
binding.pauseLevelBadge?.setThemeColor(getThemeColor(currentTheme)) binding.pauseLevelBadge?.setThemeColor(getThemeColor(currentTheme))
// Get theme color // Get theme colors
val textColor = getThemeColor(currentTheme) val themeColor = getThemeColor(currentTheme)
val backgroundColor = getThemeBackgroundColor(currentTheme)
// Apply theme color to pause container background // Apply background color to pause container
val backgroundColor = when (currentTheme) { binding.pauseContainer.setBackgroundColor(backgroundColor)
// Apply theme colors to buttons and toggles
binding.pauseStartButton.setTextColor(themeColor)
binding.pauseRestartButton.setTextColor(themeColor)
binding.resumeButton.setTextColor(themeColor)
binding.highScoresButton.setTextColor(themeColor)
binding.statsButton.setTextColor(themeColor)
binding.pauseLevelUpButton?.setTextColor(themeColor) // Safe call
binding.pauseLevelDownButton?.setTextColor(themeColor) // Safe call
binding.settingsButton?.setTextColor(themeColor) // Safe call for sound toggle button text
binding.musicToggle?.setColorFilter(themeColor) // Safe call
// Apply theme colors to text elements (using safe calls)
binding.settingsTitle?.setTextColor(themeColor)
binding.selectLevelText?.setTextColor(themeColor)
binding.musicText?.setTextColor(themeColor)
binding.pauseLevelText?.setTextColor(themeColor)
// Apply theme to specific labels in portrait/landscape - RELY ON HELPER BELOW
if (isLandscape) {
// findViewById<TextView>(R.id.selectThemeTextLandscape)?.setTextColor(themeColor) // REMOVED
// findViewById<TextView>(R.id.selectBlockSkinTextLandscape)?.setTextColor(themeColor) // REMOVED
// Also color general text views in the landscape container, avoiding selectors
applyThemeColorToTextViews(findViewById(R.id.pauseContainer), themeColor)
} else {
// binding.selectThemeText?.setTextColor(themeColor) // REMOVED
// binding.selectBlockSkinText?.setTextColor(themeColor) // REMOVED
// Apply to portrait container as well if needed (assuming root or specific container)
applyThemeColorToTextViews(binding.pauseContainer, themeColor) // Apply to main container
}
// Update theme selector instances
val currentUnlockedThemes = progressionManager.getUnlockedThemes()
binding.themeSelector.updateThemes(currentUnlockedThemes, currentTheme)
findViewById<ThemeSelector>(R.id.inPauseThemeSelector)?.updateThemes(currentUnlockedThemes, currentTheme)
// Update block skin selector instances
val currentBlockSkin = gameView.getCurrentBlockSkin()
val currentUnlockedBlocks = progressionManager.getUnlockedBlocks()
val currentLevel = progressionManager.getPlayerLevel()
binding.blockSkinSelector.updateBlockSkins(currentUnlockedBlocks, currentBlockSkin, currentLevel)
findViewById<BlockSkinSelector>(R.id.inPauseBlockSkinSelector)?.updateBlockSkins(currentUnlockedBlocks, currentBlockSkin, currentLevel)
// Reset scroll position
binding.pauseMenuScrollView?.scrollTo(0, 0)
findLandscapeScrollView()?.scrollTo(0, 0)
// Initialize pause menu navigation (builds the list of navigable items)
initPauseMenuNavigation()
// Reset selection index (will be set and highlighted in showPauseMenu)
currentMenuSelection = 0
// Highlight the initially selected menu item
if (pauseMenuItems.isNotEmpty()) {
highlightMenuItem(currentMenuSelection)
}
}
/** Helper to apply theme color to TextViews within a container, avoiding selectors */
private fun applyThemeColorToTextViews(container: View?, themeColor: Int) {
if (container == null) return
if (container is android.view.ViewGroup) {
for (i in 0 until container.childCount) {
val child = container.getChildAt(i)
if (child is TextView) {
// Check if parent is a selector
var parent = child.parent
var isInSelector = false
while (parent != null) {
if (parent is ThemeSelector || parent is BlockSkinSelector) {
isInSelector = true
break
}
if (parent === container) break // Stop at the container boundary
parent = parent.parent
}
if (!isInSelector) {
child.setTextColor(themeColor)
}
} else if (child is android.view.ViewGroup) {
// Recurse only if not a selector
if (child !is ThemeSelector && child !is BlockSkinSelector) {
applyThemeColorToTextViews(child, themeColor)
}
}
}
}
}
/** Helper to get background color based on theme */
private fun getThemeBackgroundColor(themeId: String): Int {
return when (themeId) {
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221") PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A") PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
@ -572,58 +679,6 @@ class MainActivity : AppCompatActivity(),
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10") PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
else -> Color.BLACK else -> Color.BLACK
} }
binding.pauseContainer.setBackgroundColor(backgroundColor)
// Apply theme colors to buttons
binding.pauseStartButton.setTextColor(textColor)
binding.pauseRestartButton.setTextColor(textColor)
binding.resumeButton.setTextColor(textColor)
binding.highScoresButton.setTextColor(textColor)
binding.statsButton.setTextColor(textColor)
binding.pauseLevelText?.setTextColor(textColor)
binding.pauseLevelUpButton.setTextColor(textColor)
binding.pauseLevelDownButton.setTextColor(textColor)
binding.settingsButton.setTextColor(textColor)
binding.musicToggle.setColorFilter(textColor)
// Apply theme colors to text elements
binding.settingsTitle?.setTextColor(textColor)
binding.selectLevelText.setTextColor(textColor)
binding.musicText.setTextColor(textColor)
// Update theme selector - handle both standard and landscape versions
updateThemeSelector()
// Handle landscape mode theme selectors (using null-safe calls)
val inPauseThemeSelector = findViewById<ThemeSelector>(R.id.inPauseThemeSelector)
inPauseThemeSelector?.updateThemes(
unlockedThemes = progressionManager.getUnlockedThemes(),
currentTheme = currentTheme
)
// Update block skin selector - handle both standard and landscape versions
blockSkinSelector.updateBlockSkins(
progressionManager.getUnlockedBlocks(),
gameView.getCurrentBlockSkin(),
progressionManager.getPlayerLevel()
)
// Handle landscape mode block skin selectors (using null-safe calls)
val inPauseBlockSkinSelector = findViewById<BlockSkinSelector>(R.id.inPauseBlockSkinSelector)
inPauseBlockSkinSelector?.updateBlockSkins(
progressionManager.getUnlockedBlocks(),
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)
}
} }
/** /**
@ -1181,17 +1236,39 @@ class MainActivity : AppCompatActivity(),
} }
} }
override fun onMenuLeft() {
runOnUiThread {
if (binding.pauseContainer.visibility == View.VISIBLE && pauseMenuItems.isNotEmpty()) {
val selectedItem = pauseMenuItems.getOrNull(currentMenuSelection)
when (selectedItem) {
is ThemeSelector -> selectedItem.focusPreviousItem()
is BlockSkinSelector -> selectedItem.focusPreviousItem()
}
}
}
}
override fun onMenuRight() {
runOnUiThread {
if (binding.pauseContainer.visibility == View.VISIBLE && pauseMenuItems.isNotEmpty()) {
val selectedItem = pauseMenuItems.getOrNull(currentMenuSelection)
when (selectedItem) {
is ThemeSelector -> selectedItem.focusNextItem()
is BlockSkinSelector -> selectedItem.focusNextItem()
}
}
}
}
override fun onMenuSelect() { override fun onMenuSelect() {
runOnUiThread { runOnUiThread {
if (binding.pauseContainer.visibility == View.VISIBLE) { if (binding.pauseContainer.visibility == View.VISIBLE && pauseMenuItems.isNotEmpty()) {
activateSelectedMenuItem() activateSelectedMenuItem() // Handles selectors and other items
} else if (titleScreen.visibility == View.VISIBLE) { } else if (titleScreen.visibility == View.VISIBLE) {
titleScreen.performClick() titleScreen.performClick()
} else if (progressionScreen.visibility == View.VISIBLE) { } else if (progressionScreen.visibility == View.VISIBLE) {
// Handle continue in progression screen
progressionScreen.performContinue() progressionScreen.performContinue()
} else if (binding.gameOverContainer.visibility == View.VISIBLE) { } else if (binding.gameOverContainer.visibility == View.VISIBLE) {
// Handle play again in game over screen
binding.playAgainButton.performClick() binding.playAgainButton.performClick()
} }
} }
@ -1202,47 +1279,158 @@ class MainActivity : AppCompatActivity(),
*/ */
private fun initPauseMenuNavigation() { private fun initPauseMenuNavigation() {
pauseMenuItems.clear() 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 // Check landscape mode only to select the *correct instance* of the view
val themeSelector = binding.themeSelector val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
if (themeSelector.visibility == View.VISIBLE) {
pauseMenuItems.add(themeSelector)
}
// Add block skin selector if present // Define the unified order (Portrait Order)
val blockSkinSelector = binding.blockSkinSelector val orderedViews = mutableListOf<View?>()
if (blockSkinSelector.visibility == View.VISIBLE) {
pauseMenuItems.add(blockSkinSelector) // Group 1: Game control buttons (Resume/Start New Game, Restart)
if (binding.resumeButton.visibility == View.VISIBLE) {
orderedViews.add(binding.resumeButton)
} }
if (binding.pauseStartButton.visibility == View.VISIBLE) {
// In pause menu, the 'Start New Game' button might be visible if paused from title?
// Let's assume pauseRestartButton is the primary one during active pause.
// Keep the logic simple: add if visible.
orderedViews.add(binding.pauseStartButton)
}
orderedViews.add(binding.pauseRestartButton) // Always add restart
// Group 2: Stats and Scoring
orderedViews.add(binding.highScoresButton)
orderedViews.add(binding.statsButton)
// Group 3: Level selection (use safe calls)
orderedViews.add(binding.pauseLevelUpButton)
orderedViews.add(binding.pauseLevelDownButton)
// Group 4: Sound and Music controls (Moved UP) - Use safe calls
orderedViews.add(binding.settingsButton) // Sound toggle (Button)
orderedViews.add(binding.musicToggle) // Music toggle (ImageButton)
// Group 5: Visual customization (Theme and Block Skins)
// Choose the correct instance based on orientation and visibility
val themeSelectorInstance = if (isLandscape) findViewById<ThemeSelector>(R.id.inPauseThemeSelector) else binding.themeSelector
val blockSkinSelectorInstance = if (isLandscape) findViewById<BlockSkinSelector>(R.id.inPauseBlockSkinSelector) else binding.blockSkinSelector
// Only add the selectors if they are actually visible in the current layout
if (themeSelectorInstance?.visibility == View.VISIBLE) {
orderedViews.add(themeSelectorInstance)
}
if (blockSkinSelectorInstance?.visibility == View.VISIBLE) {
orderedViews.add(blockSkinSelectorInstance)
}
// Add all non-null, visible items from the defined order to the final navigation list
pauseMenuItems.addAll(orderedViews.filterNotNull().filter { it.visibility == View.VISIBLE })
// Reset selection index (will be set and highlighted in showPauseMenu)
// currentMenuSelection = 0
} }
/** /**
* Highlight the currently selected menu item * Highlight the currently selected menu item
*/ */
private fun highlightMenuItem(index: Int) { private fun highlightMenuItem(index: Int) {
// Reset all items to normal state // Ensure index is valid
if (pauseMenuItems.isEmpty() || index < 0 || index >= pauseMenuItems.size) {
Log.w(TAG, "highlightMenuItem called with invalid index: $index or empty items")
return
}
val themeColor = getThemeColor(currentTheme)
val highlightBorderColor = themeColor // Use theme color for the main highlight border
pauseMenuItems.forEachIndexed { i, item -> pauseMenuItems.forEachIndexed { i, item ->
item.alpha = if (i == index) 1.0f else 0.7f val isSelected = (i == index)
item.scaleX = if (i == index) 1.2f else 1.0f
item.scaleY = if (i == index) 1.2f else 1.0f // Reset common properties
// Add or remove border based on selection item.alpha = if (isSelected) 1.0f else 0.7f
if (item is Button) { if (item is Button || item is ImageButton) {
item.background = if (i == index) { item.scaleX = 1.0f
ContextCompat.getDrawable(this, R.drawable.menu_item_selected) item.scaleY = 1.0f
} else { }
ColorDrawable(Color.TRANSPARENT) // Reset background/focus state for all items before applying highlight
when (item) {
is Button -> item.background = ColorDrawable(Color.TRANSPARENT)
is ImageButton -> item.background = null // Let default background handle non-selected
is ThemeSelector -> {
item.background = ColorDrawable(Color.TRANSPARENT)
item.setHasFocus(false) // Explicitly remove component focus
}
is BlockSkinSelector -> {
item.background = ColorDrawable(Color.TRANSPARENT)
item.setHasFocus(false) // Explicitly remove component focus
}
}
// Apply highlight to the selected item
if (isSelected) {
// Apply scaling to buttons
if (item is Button || item is ImageButton) {
item.scaleX = 1.1f
item.scaleY = 1.1f
}
// Add border/background based on type
val borderWidth = 4 // Unified border width for highlight
val cornerRadius = 12f
when (item) {
is Button -> {
// Attempt to use a themed drawable, fallback to simple border
ContextCompat.getDrawable(this, R.drawable.menu_item_selected)?.mutate()?.let {
if (it is GradientDrawable) {
it.setStroke(borderWidth, highlightBorderColor)
item.background = it
} else {
// Fallback for non-gradient drawable (e.g., tinting)
it.setTint(highlightBorderColor)
item.background = it
}
} ?: run {
// Absolute fallback: create a border if drawable fails
item.background = GradientDrawable().apply {
setColor(Color.TRANSPARENT)
setStroke(borderWidth, highlightBorderColor)
setCornerRadius(cornerRadius)
}
}
}
is ImageButton -> {
item.background = GradientDrawable().apply {
setColor(Color.TRANSPARENT) // Keep original button look, just add border
setStroke(borderWidth, highlightBorderColor)
setCornerRadius(cornerRadius)
}
}
is ThemeSelector -> {
item.background = GradientDrawable().apply {
setColor(Color.TRANSPARENT)
setStroke(borderWidth, highlightBorderColor)
setCornerRadius(cornerRadius)
}
item.setHasFocus(true) // Tell selector it HAS component focus
}
is BlockSkinSelector -> {
item.background = GradientDrawable().apply {
setColor(Color.TRANSPARENT)
setStroke(borderWidth, highlightBorderColor)
setCornerRadius(cornerRadius)
}
item.setHasFocus(true) // Tell selector it HAS component focus
}
} }
} }
} }
// Provide haptic feedback for selection change
gameHaptics.vibrateForPieceMove()
// Ensure the selected item is visible
scrollToSelectedItem()
} }
/** /**
@ -1250,10 +1438,21 @@ class MainActivity : AppCompatActivity(),
*/ */
private fun moveMenuSelectionUp() { private fun moveMenuSelectionUp() {
if (pauseMenuItems.isEmpty()) return if (pauseMenuItems.isEmpty()) return
currentMenuSelection = (currentMenuSelection - 1 + pauseMenuItems.size) % pauseMenuItems.size
highlightMenuItem(currentMenuSelection) // Calculate new selection index with wrapping
scrollToSelectedItem() val previousSelection = currentMenuSelection
gameHaptics.vibrateForPieceMove() currentMenuSelection = (currentMenuSelection - 1)
// Prevent wrapping from bottom to top for more intuitive navigation
if (currentMenuSelection < 0) {
currentMenuSelection = 0
return // Already at top, don't provide feedback
}
// Only provide feedback if selection actually changed
if (previousSelection != currentMenuSelection) {
highlightMenuItem(currentMenuSelection)
}
} }
/** /**
@ -1261,30 +1460,82 @@ class MainActivity : AppCompatActivity(),
*/ */
private fun moveMenuSelectionDown() { private fun moveMenuSelectionDown() {
if (pauseMenuItems.isEmpty()) return if (pauseMenuItems.isEmpty()) return
currentMenuSelection = (currentMenuSelection + 1) % pauseMenuItems.size
highlightMenuItem(currentMenuSelection) // Calculate new selection index with wrapping
scrollToSelectedItem() val previousSelection = currentMenuSelection
gameHaptics.vibrateForPieceMove() currentMenuSelection = (currentMenuSelection + 1)
// Prevent wrapping from bottom to top for more intuitive navigation
if (currentMenuSelection >= pauseMenuItems.size) {
currentMenuSelection = pauseMenuItems.size - 1
return // Already at bottom, don't provide feedback
}
// Only provide feedback if selection actually changed
if (previousSelection != currentMenuSelection) {
highlightMenuItem(currentMenuSelection)
}
} }
/**
* Scroll the selected menu item into view
*/
private fun scrollToSelectedItem() { private fun scrollToSelectedItem() {
if (pauseMenuItems.isEmpty()) return if (pauseMenuItems.isEmpty() || currentMenuSelection < 0 || currentMenuSelection >= pauseMenuItems.size) return
val selectedItem = pauseMenuItems[currentMenuSelection] val selectedItem = pauseMenuItems[currentMenuSelection]
val scrollView = pauseMenuScrollView ?: return
// Calculate the item's position relative to the scroll view // Determine which scroll view to use based on orientation
val itemTop = selectedItem.top val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
val itemBottom = selectedItem.bottom val scrollView = if (isLandscape) {
val scrollViewHeight = scrollView.height findLandscapeScrollView()
} else {
binding.pauseMenuScrollView
} ?: return
// If the item is partially or fully below the visible area, scroll down // Delay the scrolling slightly to ensure measurements are correct
if (itemBottom > scrollViewHeight) { scrollView.post {
scrollView.smoothScrollTo(0, itemBottom - scrollViewHeight) try {
} // Calculate item's position in the ScrollView's coordinate system
// If the item is partially or fully above the visible area, scroll up val scrollBounds = Rect()
else if (itemTop < 0) { scrollView.getHitRect(scrollBounds)
scrollView.smoothScrollTo(0, itemTop)
// Get item's location on screen
val itemLocation = IntArray(2)
selectedItem.getLocationOnScreen(itemLocation)
// Get ScrollView's location on screen
val scrollLocation = IntArray(2)
scrollView.getLocationOnScreen(scrollLocation)
// Calculate relative position
val itemY = itemLocation[1] - scrollLocation[1]
// Get the top and bottom of the item relative to the ScrollView
val itemTop = itemY
val itemBottom = itemY + selectedItem.height
// Get the visible height of the ScrollView
val visibleHeight = scrollView.height
// Add padding for better visibility
val padding = 50
// Calculate the scroll target position
val scrollY = scrollView.scrollY
// If item is above visible area
if (itemTop < padding) {
scrollView.smoothScrollTo(0, scrollY + (itemTop - padding))
}
// If item is below visible area
else if (itemBottom > visibleHeight - padding) {
scrollView.smoothScrollTo(0, scrollY + (itemBottom - visibleHeight + padding))
}
} catch (e: Exception) {
// Handle any exceptions that might occur during scrolling
Log.e(TAG, "Error scrolling to selected item", e)
}
} }
} }
@ -1293,6 +1544,65 @@ class MainActivity : AppCompatActivity(),
*/ */
private fun activateSelectedMenuItem() { private fun activateSelectedMenuItem() {
if (pauseMenuItems.isEmpty() || currentMenuSelection < 0 || currentMenuSelection >= pauseMenuItems.size) return if (pauseMenuItems.isEmpty() || currentMenuSelection < 0 || currentMenuSelection >= pauseMenuItems.size) return
pauseMenuItems[currentMenuSelection].performClick()
val selectedItem = pauseMenuItems[currentMenuSelection]
// Vibrate regardless of item type for consistent feedback
gameHaptics.vibrateForPieceLock()
when (selectedItem) {
is ThemeSelector -> {
// Confirm the internally focused theme
selectedItem.confirmSelection()
// Applying the theme might change colors, so refresh the pause menu display
// Need to delay slightly to allow theme application to potentially finish
Handler(Looper.getMainLooper()).postDelayed({ showPauseMenu() }, 100)
}
is BlockSkinSelector -> {
// Confirm the internally focused block skin
selectedItem.confirmSelection()
// Skin change doesn't affect menu colors, no refresh needed here
}
else -> {
// Handle other menu items with a standard click
selectedItem.performClick()
}
}
}
/**
* Find any ScrollView in the pause container for landscape mode
*/
private fun findLandscapeScrollView(): ScrollView? {
// First try to find a ScrollView directly in the landscape layout
val pauseContainer = findViewById<View>(R.id.pauseContainer) ?: return null
// Try to find any ScrollView in the pause container
if (pauseContainer is android.view.ViewGroup) {
// Search for ScrollView in the container
fun findScrollView(view: android.view.View): ScrollView? {
if (view is ScrollView && view.visibility == View.VISIBLE) {
return view
} else if (view is android.view.ViewGroup) {
for (i in 0 until view.childCount) {
val child = view.getChildAt(i)
val scrollView = findScrollView(child)
if (scrollView != null) {
return scrollView
}
}
}
return null
}
// Try to find ScrollView in the container
val scrollView = findScrollView(pauseContainer)
if (scrollView != null && scrollView != binding.pauseMenuScrollView) {
return scrollView
}
}
// If no landscape ScrollView is found, fall back to the default one
return binding.pauseMenuScrollView
} }
} }

View file

@ -153,6 +153,8 @@ class GamepadController(
fun onMenuUp() fun onMenuUp()
fun onMenuDown() fun onMenuDown()
fun onMenuSelect() fun onMenuSelect()
fun onMenuLeft()
fun onMenuRight()
} }
// Listeners // Listeners
@ -279,6 +281,14 @@ class GamepadController(
navigationListener?.onMenuDown() navigationListener?.onMenuDown()
return true return true
} }
KeyEvent.KEYCODE_DPAD_LEFT -> {
navigationListener?.onMenuLeft()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
navigationListener?.onMenuRight()
return true
}
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_A,
KeyEvent.KEYCODE_DPAD_CENTER -> { KeyEvent.KEYCODE_DPAD_CENTER -> {
navigationListener?.onMenuSelect() navigationListener?.onMenuSelect()

View file

@ -11,6 +11,8 @@ import android.widget.TextView
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import com.mintris.R import com.mintris.R
import com.mintris.model.PlayerProgressionManager import com.mintris.model.PlayerProgressionManager
import android.animation.ValueAnimator
import android.graphics.drawable.GradientDrawable
/** /**
* UI component for selecting block skins * UI component for selecting block skins
@ -27,11 +29,19 @@ class BlockSkinSelector @JvmOverloads constructor(
// Callback when a block skin is selected // Callback when a block skin is selected
var onBlockSkinSelected: ((String) -> Unit)? = null var onBlockSkinSelected: ((String) -> Unit)? = null
// Currently selected block skin // Currently selected block skin (persisted)
private var selectedSkin: String = "block_skin_1" private var selectedSkin: String = "block_skin_1"
// Block skin cards // Block skin cards map (skinId -> CardView)
private val skinCards = mutableMapOf<String, CardView>() private val skinCards = mutableMapOf<String, CardView>()
// Ordered list of skin IDs for navigation
private val skinIdList = mutableListOf<String>()
// Currently focused skin ID (for gamepad navigation within the selector)
private var focusedSkinId: String? = null
// Index of the currently focused skin in skinIdList
private var focusedIndex: Int = -1
// Flag indicating if the entire selector component has focus from the main menu
private var hasComponentFocus: Boolean = false
// Player level for determining what should be unlocked // Player level for determining what should be unlocked
private var playerLevel: Int = 1 private var playerLevel: Int = 1
@ -52,69 +62,47 @@ class BlockSkinSelector @JvmOverloads constructor(
// Store player level // Store player level
this.playerLevel = playerLevel this.playerLevel = playerLevel
// Clear existing skin cards // Clear existing skin cards and ID list
skinsGrid.removeAllViews() skinsGrid.removeAllViews()
skinCards.clear() skinCards.clear()
skinIdList.clear()
// Update selected skin // Update selected skin and initial focus
selectedSkin = currentSkin selectedSkin = currentSkin
focusedSkinId = currentSkin
focusedIndex = -1 // Reset index
// Get all possible skins and their details // Get all possible skins and their details, sorted for consistent order
val allSkins = getBlockSkins() val allSkins = getBlockSkins().entries.sortedWith(compareBy({ it.value.unlockLevel }, { it.value.displayName })).associate { it.key to it.value }
// Add skin cards to the grid // Add skin cards to the grid and build ID list
allSkins.forEach { (skinId, skinInfo) -> allSkins.forEach { (skinId, skinInfo) ->
val isUnlocked = unlockedSkins.contains(skinId) || playerLevel >= skinInfo.unlockLevel val isEffectivelyUnlocked = unlockedSkins.contains(skinId) || playerLevel >= skinInfo.unlockLevel
val isSelected = skinId == selectedSkin val isSelected = skinId == selectedSkin
val skinCard = createBlockSkinCard(skinId, skinInfo, isUnlocked, isSelected) // Only add unlocked skins to the navigable list
if (isEffectivelyUnlocked) {
skinIdList.add(skinId)
}
val skinCard = createBlockSkinCard(skinId, skinInfo, isEffectivelyUnlocked, isSelected)
skinCards[skinId] = skinCard skinCards[skinId] = skinCard
skinsGrid.addView(skinCard) skinsGrid.addView(skinCard)
}
} // Update focused index if this is the currently selected/focused skin
if (isEffectivelyUnlocked && skinId == focusedSkinId) {
/** focusedIndex = skinIdList.indexOf(skinId)
* Set the selected skin with a visual effect
*/
fun setSelectedSkin(skinId: String) {
if (skinId == selectedSkin) return
// Update previously selected card
skinCards[selectedSkin]?.let { prevCard ->
prevCard.cardElevation = 2f
// Reset any special styling
prevCard.background = null
prevCard.setCardBackgroundColor(getBlockSkins()[selectedSkin]?.backgroundColor ?: Color.BLACK)
}
// Update visual state of newly selected card
skinCards[skinId]?.let { card ->
card.cardElevation = 12f
// Flash animation for selection feedback
val flashColor = Color.WHITE
val originalColor = getBlockSkins()[skinId]?.backgroundColor ?: Color.BLACK
// Create animator for flash effect
val flashAnimator = android.animation.ValueAnimator.ofArgb(flashColor, originalColor)
flashAnimator.duration = 300 // 300ms
flashAnimator.addUpdateListener { animator ->
val color = animator.animatedValue as Int
card.setCardBackgroundColor(color)
} }
flashAnimator.start()
// Add special border to selected card
val gradientDrawable = android.graphics.drawable.GradientDrawable().apply {
setColor(originalColor)
setStroke(6, Color.WHITE) // Thicker border
cornerRadius = 12f
}
card.background = gradientDrawable
} }
// Update selected skin // Ensure focus index is valid if the previously focused skin is no longer available/unlocked
selectedSkin = skinId if (focusedIndex == -1 && skinIdList.isNotEmpty()) {
focusedIndex = 0
focusedSkinId = skinIdList[0]
}
// Apply initial focus highlight if the component has focus
highlightFocusedCard()
} }
/** /**
@ -136,20 +124,6 @@ class BlockSkinSelector @JvmOverloads constructor(
// Set card background color based on skin // Set card background color based on skin
setCardBackgroundColor(skinInfo.backgroundColor) setCardBackgroundColor(skinInfo.backgroundColor)
// Add more noticeable visual indicator for selected skin
if (isSelected) {
setContentPadding(4, 4, 4, 4)
// Create a gradient drawable for the border
val gradientDrawable = android.graphics.drawable.GradientDrawable().apply {
setColor(skinInfo.backgroundColor)
setStroke(6, Color.WHITE) // Thicker border
cornerRadius = 12f
}
background = gradientDrawable
// Add glow effect via elevation
cardElevation = 12f
}
// Set card dimensions // Set card dimensions
val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size) val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size)
layoutParams = GridLayout.LayoutParams().apply { layoutParams = GridLayout.LayoutParams().apply {
@ -160,7 +134,7 @@ class BlockSkinSelector @JvmOverloads constructor(
setMargins(8, 8, 8, 8) setMargins(8, 8, 8, 8)
} }
// Apply locked/selected state visuals // Apply locked/selected state visuals (only opacity here)
alpha = if (isUnlocked) 1.0f else 0.5f alpha = if (isUnlocked) 1.0f else 0.5f
} }
@ -244,14 +218,10 @@ class BlockSkinSelector @JvmOverloads constructor(
// Set up click listener only for unlocked skins // Set up click listener only for unlocked skins
if (isUnlocked) { if (isUnlocked) {
card.setOnClickListener { card.setOnClickListener {
// Only trigger callback if this isn't already the selected skin // Clicking directly selects the skin
if (skinId != selectedSkin) { focusedSkinId = skinId
// Update UI for selection focusedIndex = skinIdList.indexOf(skinId)
setSelectedSkin(skinId) confirmSelection() // Directly confirm click selection
// Notify listener
onBlockSkinSelected?.invoke(skinId)
}
} }
} }
@ -305,4 +275,159 @@ class BlockSkinSelector @JvmOverloads constructor(
) )
) )
} }
/**
* Sets whether the entire component has focus (from the parent menu).
*/
fun setHasFocus(hasFocus: Boolean) {
hasComponentFocus = hasFocus
highlightFocusedCard() // Re-apply highlights
}
/**
* Moves the internal focus to the next available skin.
*/
fun focusNextItem() {
if (!hasComponentFocus || skinIdList.isEmpty()) return
focusedIndex = (focusedIndex + 1) % skinIdList.size
focusedSkinId = skinIdList[focusedIndex]
highlightFocusedCard()
}
/**
* Moves the internal focus to the previous available skin.
*/
fun focusPreviousItem() {
if (!hasComponentFocus || skinIdList.isEmpty()) return
focusedIndex = (focusedIndex - 1 + skinIdList.size) % skinIdList.size
focusedSkinId = skinIdList[focusedIndex]
highlightFocusedCard()
}
/**
* Confirms the currently focused skin as the selected skin.
* Triggers the onBlockSkinSelected callback.
*/
fun confirmSelection() {
if (!hasComponentFocus || focusedSkinId == null || focusedSkinId == selectedSkin) {
return // No change needed
}
// Update the selected skin
val newlySelectedSkin = focusedSkinId!!
selectedSkin = newlySelectedSkin
// Update visual states
highlightFocusedCard()
// Trigger the callback
onBlockSkinSelected?.invoke(selectedSkin)
}
/**
* Updates the visual highlight state of the skin cards based on
* selection and internal focus.
*/
private fun highlightFocusedCard() {
if (skinCards.isEmpty()) return
val focusColor = Color.YELLOW // Color for focused-but-not-selected
val selectedColor = Color.WHITE // Color for selected (might be focused or not)
skinCards.forEach { (skinId, card) ->
val skinInfo = getBlockSkins()[skinId] ?: return@forEach
// Check unlock status based on the navigable list derived from level/unlocks
val isUnlocked = skinIdList.contains(skinId)
if (!isUnlocked) {
// Keep locked skins visually distinct
card.alpha = 0.5f
card.cardElevation = 2f
card.background = null
card.setCardBackgroundColor(skinInfo.backgroundColor)
card.scaleX = 1.0f // Reset scale
card.scaleY = 1.0f
return@forEach
}
// Reset unlocked cards first
card.alpha = 1.0f
card.cardElevation = 4f
card.background = null
card.setCardBackgroundColor(skinInfo.backgroundColor)
card.scaleX = 1.0f
card.scaleY = 1.0f
val isSelected = (skinId == selectedSkin)
val isFocused = (hasComponentFocus && skinId == focusedSkinId)
var borderColor = Color.TRANSPARENT
var borderWidth = 0
var elevation = 4f
var scale = 1.0f
if (isSelected) {
borderColor = selectedColor
borderWidth = 6 // Thick border for selected
elevation = 12f // Higher elevation for selected
}
if (isFocused) {
// Focused item gets a distinct border (unless it's also selected)
if (!isSelected) {
borderColor = focusColor
borderWidth = 4 // Slightly thinner border for focused
}
elevation = 12f // Use high elevation for focus too
scale = 1.1f // Scale up the focused item
}
// Apply scale
card.scaleX = scale
card.scaleY = scale
// Apply border and elevation
if (borderWidth > 0) {
val gradientDrawable = GradientDrawable().apply {
setColor(skinInfo.backgroundColor) // Use skin's background for the fill
setStroke(borderWidth, borderColor)
cornerRadius = 12f
}
card.background = gradientDrawable
} else {
card.background = null // Ensure no border if not selected/focused
}
card.cardElevation = elevation
}
}
// Keep selectNextBlockSkin temporarily for compatibility, but it shouldn't be called by MainActivity anymore
fun selectNextBlockSkin() {
val allSkins = getBlockSkins().keys.toList()
val currentIndex = allSkins.indexOf(selectedSkin)
if (currentIndex == -1) return
var nextIndex = (currentIndex + 1) % allSkins.size
while (nextIndex != currentIndex) {
val nextSkin = allSkins[nextIndex]
val skinInfo = getBlockSkins()[nextSkin] ?: continue
val isEffectivelyUnlocked = skinCards[nextSkin]?.alpha == 1.0f // Basic check based on alpha
|| playerLevel >= skinInfo.unlockLevel
if (isEffectivelyUnlocked) {
// This method now just sets the internal focus and confirms
focusedSkinId = nextSkin
focusedIndex = skinIdList.indexOf(nextSkin) // Update index based on navigable list
if (focusedIndex == -1) { // If not found in navigable list, reset focus
focusedIndex = 0
focusedSkinId = if (skinIdList.isNotEmpty()) skinIdList[0] else null
}
confirmSelection() // Confirm the selection
return
}
nextIndex = (nextIndex + 1) % allSkins.size
}
}
} }

View file

@ -11,6 +11,8 @@ import android.widget.TextView
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import com.mintris.R import com.mintris.R
import com.mintris.model.PlayerProgressionManager import com.mintris.model.PlayerProgressionManager
import android.animation.ValueAnimator
import android.graphics.drawable.GradientDrawable
/** /**
* UI component for selecting game themes * UI component for selecting game themes
@ -27,11 +29,19 @@ class ThemeSelector @JvmOverloads constructor(
// Callback when a theme is selected // Callback when a theme is selected
var onThemeSelected: ((String) -> Unit)? = null var onThemeSelected: ((String) -> Unit)? = null
// Currently selected theme // Currently selected theme (persisted)
private var selectedTheme: String = PlayerProgressionManager.THEME_CLASSIC private var selectedTheme: String = PlayerProgressionManager.THEME_CLASSIC
// Theme cards // Theme cards map (themeId -> CardView)
private val themeCards = mutableMapOf<String, CardView>() private val themeCards = mutableMapOf<String, CardView>()
// Ordered list of theme IDs for navigation
private val themeIdList = mutableListOf<String>()
// Currently focused theme ID (for gamepad navigation within the selector)
private var focusedThemeId: String? = null
// Index of the currently focused theme in themeIdList
private var focusedIndex: Int = -1
// Flag indicating if the entire selector component has focus from the main menu
private var hasComponentFocus: Boolean = false
init { init {
// Inflate the layout // Inflate the layout
@ -46,25 +56,47 @@ class ThemeSelector @JvmOverloads constructor(
* Update the theme selector with unlocked themes * Update the theme selector with unlocked themes
*/ */
fun updateThemes(unlockedThemes: Set<String>, currentTheme: String) { fun updateThemes(unlockedThemes: Set<String>, currentTheme: String) {
// Clear existing theme cards // Clear existing theme cards and ID list
themesGrid.removeAllViews() themesGrid.removeAllViews()
themeCards.clear() themeCards.clear()
themeIdList.clear()
// Update selected theme // Update selected theme
selectedTheme = currentTheme selectedTheme = currentTheme
focusedThemeId = currentTheme // Initially focus the selected theme
focusedIndex = -1 // Reset index
// Get all possible themes and their details // Get all possible themes and their details, sorted for consistent order
val allThemes = getThemes() val allThemes = getThemes().entries.sortedWith(compareBy({ it.value.unlockLevel }, { it.value.displayName })).associate { it.key to it.value }
// Add theme cards to the grid // Add theme cards to the grid and build the ID list
allThemes.forEach { (themeId, themeInfo) -> allThemes.forEach { (themeId, themeInfo) ->
val isUnlocked = unlockedThemes.contains(themeId) val isUnlocked = unlockedThemes.contains(themeId)
val isSelected = themeId == selectedTheme val isSelected = themeId == selectedTheme
// Only add unlocked themes to the navigable list
if (isUnlocked) {
themeIdList.add(themeId)
}
val themeCard = createThemeCard(themeId, themeInfo, isUnlocked, isSelected) val themeCard = createThemeCard(themeId, themeInfo, isUnlocked, isSelected)
themeCards[themeId] = themeCard themeCards[themeId] = themeCard
themesGrid.addView(themeCard) themesGrid.addView(themeCard)
// Update focused index if this is the currently selected/focused theme
if (isUnlocked && themeId == focusedThemeId) {
focusedIndex = themeIdList.indexOf(themeId)
}
} }
// Ensure focus index is valid if the previously focused theme is no longer available/unlocked
if (focusedIndex == -1 && themeIdList.isNotEmpty()) {
focusedIndex = 0
focusedThemeId = themeIdList[0]
}
// Apply initial focus highlight if the component has focus
highlightFocusedCard()
} }
/** /**
@ -86,20 +118,6 @@ class ThemeSelector @JvmOverloads constructor(
// Set card background color based on theme // Set card background color based on theme
setCardBackgroundColor(themeInfo.primaryColor) setCardBackgroundColor(themeInfo.primaryColor)
// Add more noticeable visual indicator for selected theme
if (isSelected) {
setContentPadding(4, 4, 4, 4)
// Create a gradient drawable for the border
val gradientDrawable = android.graphics.drawable.GradientDrawable().apply {
setColor(themeInfo.primaryColor)
setStroke(6, Color.WHITE) // Thicker border
cornerRadius = 12f
}
background = gradientDrawable
// Add glow effect via elevation
cardElevation = 12f
}
// Set card dimensions // Set card dimensions
val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size) val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size)
layoutParams = GridLayout.LayoutParams().apply { layoutParams = GridLayout.LayoutParams().apply {
@ -194,38 +212,10 @@ class ThemeSelector @JvmOverloads constructor(
// Set up click listener only for unlocked themes // Set up click listener only for unlocked themes
if (isUnlocked) { if (isUnlocked) {
card.setOnClickListener { card.setOnClickListener {
// Only trigger callback if this isn't already the selected theme // Clicking directly selects the theme
if (themeId != selectedTheme) { focusedThemeId = themeId
// Update previously selected card focusedIndex = themeIdList.indexOf(themeId)
themeCards[selectedTheme]?.let { prevCard -> confirmSelection() // Directly confirm click selection
prevCard.cardElevation = 2f
// Reset any special styling
prevCard.background = null
prevCard.setCardBackgroundColor(getThemes()[selectedTheme]?.primaryColor ?: Color.BLACK)
}
// Update visual state of newly selected card
card.cardElevation = 12f
// Flash animation for selection feedback
val flashColor = Color.WHITE
val originalColor = themeInfo.primaryColor
// Create animator for flash effect
val flashAnimator = android.animation.ValueAnimator.ofArgb(flashColor, originalColor)
flashAnimator.duration = 300 // 300ms
flashAnimator.addUpdateListener { animator ->
val color = animator.animatedValue as Int
card.setCardBackgroundColor(color)
}
flashAnimator.start()
// Update selected theme
selectedTheme = themeId
// Notify listener
onThemeSelected?.invoke(themeId)
}
} }
} }
@ -292,4 +282,131 @@ class ThemeSelector @JvmOverloads constructor(
) )
) )
} }
/**
* Sets whether the entire component has focus (from the parent menu).
* Controls the outer border visibility.
*/
fun setHasFocus(hasFocus: Boolean) {
hasComponentFocus = hasFocus
// Update visual state based on component focus
highlightFocusedCard() // Re-apply highlights
}
/**
* Moves the internal focus to the next available theme.
*/
fun focusNextItem() {
if (!hasComponentFocus || themeIdList.isEmpty()) return // Only navigate if component has focus
focusedIndex = (focusedIndex + 1) % themeIdList.size
focusedThemeId = themeIdList[focusedIndex]
highlightFocusedCard()
}
/**
* Moves the internal focus to the previous available theme.
*/
fun focusPreviousItem() {
if (!hasComponentFocus || themeIdList.isEmpty()) return // Only navigate if component has focus
focusedIndex = (focusedIndex - 1 + themeIdList.size) % themeIdList.size
focusedThemeId = themeIdList[focusedIndex]
highlightFocusedCard()
}
/**
* Confirms the currently focused theme as the selected theme.
* Triggers the onThemeSelected callback.
*/
fun confirmSelection() {
if (!hasComponentFocus || focusedThemeId == null || focusedThemeId == selectedTheme) {
// No change needed if component doesn't have focus, nothing is focused,
// or the focused item is already selected
return
}
// Update the selected theme
selectedTheme = focusedThemeId!!
// Update visual states for all cards
highlightFocusedCard() // This will now mark the new theme as selected
// Trigger the callback
onThemeSelected?.invoke(selectedTheme)
}
/**
* Updates the visual highlight state of the theme cards based on
* selection and internal focus.
*/
private fun highlightFocusedCard() {
if (themeCards.isEmpty()) return
val focusColor = Color.YELLOW // Color for the focused-but-not-selected item
val selectedColor = Color.WHITE // Color for the selected item (might be focused or not)
themeCards.forEach { (themeId, card) ->
val themeInfo = getThemes()[themeId] ?: return@forEach
val isUnlocked = themeIdList.contains(themeId) // Check if it's in the navigable list
if (!isUnlocked) {
// Keep locked themes visually distinct
card.alpha = 0.5f
card.cardElevation = 2f
card.background = null // Remove any border/background
card.setCardBackgroundColor(themeInfo.primaryColor)
return@forEach // Skip further styling for locked themes
}
// Reset unlocked cards first
card.alpha = 1.0f
card.cardElevation = 4f // Default elevation for unlocked cards
card.background = null
card.setCardBackgroundColor(themeInfo.primaryColor)
card.scaleX = 1.0f
card.scaleY = 1.0f
val isSelected = (themeId == selectedTheme)
val isFocused = (hasComponentFocus && themeId == focusedThemeId)
var borderColor = Color.TRANSPARENT
var borderWidth = 0
var elevation = 4f
var scale = 1.0f
if (isSelected) {
borderColor = selectedColor
borderWidth = 6 // Thick border for selected
elevation = 12f // Higher elevation for selected
}
if (isFocused) {
// Focused item gets a distinct border (unless it's also selected)
if (!isSelected) {
borderColor = focusColor
borderWidth = 4 // Slightly thinner border for focused
}
elevation = 12f // Use high elevation for focus too
scale = 1.1f // Scale up the focused item
}
// Apply scale
card.scaleX = scale
card.scaleY = scale
// Apply border and elevation
if (borderWidth > 0) {
val gradientDrawable = GradientDrawable().apply {
setColor(themeInfo.primaryColor)
setStroke(borderWidth, borderColor)
cornerRadius = 12f // Keep consistent corner radius
}
card.background = gradientDrawable
} else {
card.background = null // Ensure no border if not selected/focused
}
card.cardElevation = elevation
}
}
} }

View file

@ -472,22 +472,7 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<!-- Theme Selector --> <!-- Sound Toggle -->
<com.mintris.ui.ThemeSelector
android:id="@+id/themeSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<!-- Block Skin Selector -->
<com.mintris.ui.BlockSkinSelector
android:id="@+id/blockSkinSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<Button <Button
android:id="@+id/settingsButton" android:id="@+id/settingsButton"
android:layout_width="200dp" android:layout_width="200dp"
@ -501,6 +486,7 @@
android:fontFamily="sans-serif" android:fontFamily="sans-serif"
android:textAllCaps="false" /> android:textAllCaps="false" />
<!-- Music Toggle -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -529,6 +515,22 @@
android:padding="12dp" android:padding="12dp"
android:src="@drawable/ic_volume_up" /> android:src="@drawable/ic_volume_up" />
</LinearLayout> </LinearLayout>
<!-- Theme Selector -->
<com.mintris.ui.ThemeSelector
android:id="@+id/themeSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<!-- Block Skin Selector -->
<com.mintris.ui.BlockSkinSelector
android:id="@+id/blockSkinSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
</LinearLayout> </LinearLayout>