From 7d7090d7ea1d3ca6aa7084d621fee846cad4e31b Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Mon, 31 Mar 2025 13:38:38 -0400 Subject: [PATCH] more gamepad support --- app/src/main/java/com/mintris/MainActivity.kt | 534 ++++++++++++++---- .../com/mintris/game/GamepadController.kt | 10 + .../java/com/mintris/ui/BlockSkinSelector.kt | 273 ++++++--- .../main/java/com/mintris/ui/ThemeSelector.kt | 221 ++++++-- app/src/main/res/layout/activity_main.xml | 34 +- 5 files changed, 818 insertions(+), 254 deletions(-) diff --git a/app/src/main/java/com/mintris/MainActivity.kt b/app/src/main/java/com/mintris/MainActivity.kt index 9ec72af..5acd71f 100644 --- a/app/src/main/java/com/mintris/MainActivity.kt +++ b/app/src/main/java/com/mintris/MainActivity.kt @@ -43,6 +43,8 @@ import androidx.core.content.ContextCompat import android.widget.Button import android.widget.ScrollView import android.widget.ImageButton +import android.graphics.drawable.GradientDrawable +import android.widget.TextView class MainActivity : AppCompatActivity(), GamepadController.GamepadConnectionListener, @@ -552,18 +554,123 @@ class MainActivity : AppCompatActivity(), */ private fun showPauseMenu() { 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 binding.pauseLevelBadge?.setLevel(progressionManager.getPlayerLevel()) binding.pauseLevelBadge?.setThemeColor(getThemeColor(currentTheme)) - // Get theme color - val textColor = getThemeColor(currentTheme) + // Get theme colors + val themeColor = getThemeColor(currentTheme) + val backgroundColor = getThemeBackgroundColor(currentTheme) - // Apply theme color to pause container background - val backgroundColor = when (currentTheme) { + // Apply background color to pause container + 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(R.id.selectThemeTextLandscape)?.setTextColor(themeColor) // REMOVED + // findViewById(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(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(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_NEON -> Color.parseColor("#0D0221") PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A") @@ -572,58 +679,6 @@ class MainActivity : AppCompatActivity(), PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10") 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(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(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() { runOnUiThread { - if (binding.pauseContainer.visibility == View.VISIBLE) { - activateSelectedMenuItem() + if (binding.pauseContainer.visibility == View.VISIBLE && pauseMenuItems.isNotEmpty()) { + activateSelectedMenuItem() // Handles selectors and other items } 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() } } @@ -1202,47 +1279,158 @@ class MainActivity : AppCompatActivity(), */ 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) - } + // Check landscape mode only to select the *correct instance* of the view + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE - // Add block skin selector if present - val blockSkinSelector = binding.blockSkinSelector - if (blockSkinSelector.visibility == View.VISIBLE) { - pauseMenuItems.add(blockSkinSelector) + // Define the unified order (Portrait Order) + val orderedViews = mutableListOf() + + // 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(R.id.inPauseThemeSelector) else binding.themeSelector + val blockSkinSelectorInstance = if (isLandscape) findViewById(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 */ 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 -> - 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) + val isSelected = (i == index) + + // Reset common properties + item.alpha = if (isSelected) 1.0f else 0.7f + if (item is Button || item is ImageButton) { + item.scaleX = 1.0f + item.scaleY = 1.0f + } + // 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() { if (pauseMenuItems.isEmpty()) return - currentMenuSelection = (currentMenuSelection - 1 + pauseMenuItems.size) % pauseMenuItems.size - highlightMenuItem(currentMenuSelection) - scrollToSelectedItem() - gameHaptics.vibrateForPieceMove() + + // Calculate new selection index with wrapping + val previousSelection = currentMenuSelection + 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() { if (pauseMenuItems.isEmpty()) return - currentMenuSelection = (currentMenuSelection + 1) % pauseMenuItems.size - highlightMenuItem(currentMenuSelection) - scrollToSelectedItem() - gameHaptics.vibrateForPieceMove() + + // Calculate new selection index with wrapping + val previousSelection = currentMenuSelection + 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() { - if (pauseMenuItems.isEmpty()) return + if (pauseMenuItems.isEmpty() || currentMenuSelection < 0 || currentMenuSelection >= pauseMenuItems.size) 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 + // Determine which scroll view to use based on orientation + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + val scrollView = if (isLandscape) { + findLandscapeScrollView() + } else { + binding.pauseMenuScrollView + } ?: return - // 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) + // Delay the scrolling slightly to ensure measurements are correct + scrollView.post { + try { + // Calculate item's position in the ScrollView's coordinate system + val scrollBounds = Rect() + scrollView.getHitRect(scrollBounds) + + // 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() { 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(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 } } \ No newline at end of file diff --git a/app/src/main/java/com/mintris/game/GamepadController.kt b/app/src/main/java/com/mintris/game/GamepadController.kt index 811dea9..c0d09a5 100644 --- a/app/src/main/java/com/mintris/game/GamepadController.kt +++ b/app/src/main/java/com/mintris/game/GamepadController.kt @@ -153,6 +153,8 @@ class GamepadController( fun onMenuUp() fun onMenuDown() fun onMenuSelect() + fun onMenuLeft() + fun onMenuRight() } // Listeners @@ -279,6 +281,14 @@ class GamepadController( navigationListener?.onMenuDown() return true } + KeyEvent.KEYCODE_DPAD_LEFT -> { + navigationListener?.onMenuLeft() + return true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + navigationListener?.onMenuRight() + return true + } KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> { navigationListener?.onMenuSelect() diff --git a/app/src/main/java/com/mintris/ui/BlockSkinSelector.kt b/app/src/main/java/com/mintris/ui/BlockSkinSelector.kt index d042059..cf3bb9e 100644 --- a/app/src/main/java/com/mintris/ui/BlockSkinSelector.kt +++ b/app/src/main/java/com/mintris/ui/BlockSkinSelector.kt @@ -11,6 +11,8 @@ import android.widget.TextView import androidx.cardview.widget.CardView import com.mintris.R import com.mintris.model.PlayerProgressionManager +import android.animation.ValueAnimator +import android.graphics.drawable.GradientDrawable /** * UI component for selecting block skins @@ -27,11 +29,19 @@ class BlockSkinSelector @JvmOverloads constructor( // Callback when a block skin is selected var onBlockSkinSelected: ((String) -> Unit)? = null - // Currently selected block skin + // Currently selected block skin (persisted) private var selectedSkin: String = "block_skin_1" - // Block skin cards + // Block skin cards map (skinId -> CardView) private val skinCards = mutableMapOf() + // Ordered list of skin IDs for navigation + private val skinIdList = mutableListOf() + // 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 private var playerLevel: Int = 1 @@ -52,69 +62,47 @@ class BlockSkinSelector @JvmOverloads constructor( // Store player level this.playerLevel = playerLevel - // Clear existing skin cards + // Clear existing skin cards and ID list skinsGrid.removeAllViews() skinCards.clear() + skinIdList.clear() - // Update selected skin + // Update selected skin and initial focus selectedSkin = currentSkin + focusedSkinId = currentSkin + focusedIndex = -1 // Reset index - // Get all possible skins and their details - val allSkins = getBlockSkins() + // Get all possible skins and their details, sorted for consistent order + 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) -> - val isUnlocked = unlockedSkins.contains(skinId) || playerLevel >= skinInfo.unlockLevel + val isEffectivelyUnlocked = unlockedSkins.contains(skinId) || playerLevel >= skinInfo.unlockLevel 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 skinsGrid.addView(skinCard) - } - } - - /** - * 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) + + // Update focused index if this is the currently selected/focused skin + if (isEffectivelyUnlocked && skinId == focusedSkinId) { + focusedIndex = skinIdList.indexOf(skinId) } - 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 - selectedSkin = skinId + // Ensure focus index is valid if the previously focused skin is no longer available/unlocked + 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 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 val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size) layoutParams = GridLayout.LayoutParams().apply { @@ -160,7 +134,7 @@ class BlockSkinSelector @JvmOverloads constructor( 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 } @@ -244,14 +218,10 @@ class BlockSkinSelector @JvmOverloads constructor( // Set up click listener only for unlocked skins if (isUnlocked) { card.setOnClickListener { - // Only trigger callback if this isn't already the selected skin - if (skinId != selectedSkin) { - // Update UI for selection - setSelectedSkin(skinId) - - // Notify listener - onBlockSkinSelected?.invoke(skinId) - } + // Clicking directly selects the skin + focusedSkinId = skinId + focusedIndex = skinIdList.indexOf(skinId) + confirmSelection() // Directly confirm click selection } } @@ -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 + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/mintris/ui/ThemeSelector.kt b/app/src/main/java/com/mintris/ui/ThemeSelector.kt index 1653ab2..81fe9b0 100644 --- a/app/src/main/java/com/mintris/ui/ThemeSelector.kt +++ b/app/src/main/java/com/mintris/ui/ThemeSelector.kt @@ -11,6 +11,8 @@ import android.widget.TextView import androidx.cardview.widget.CardView import com.mintris.R import com.mintris.model.PlayerProgressionManager +import android.animation.ValueAnimator +import android.graphics.drawable.GradientDrawable /** * UI component for selecting game themes @@ -27,11 +29,19 @@ class ThemeSelector @JvmOverloads constructor( // Callback when a theme is selected var onThemeSelected: ((String) -> Unit)? = null - // Currently selected theme + // Currently selected theme (persisted) private var selectedTheme: String = PlayerProgressionManager.THEME_CLASSIC - // Theme cards + // Theme cards map (themeId -> CardView) private val themeCards = mutableMapOf() + // Ordered list of theme IDs for navigation + private val themeIdList = mutableListOf() + // 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 { // Inflate the layout @@ -46,25 +56,47 @@ class ThemeSelector @JvmOverloads constructor( * Update the theme selector with unlocked themes */ fun updateThemes(unlockedThemes: Set, currentTheme: String) { - // Clear existing theme cards + // Clear existing theme cards and ID list themesGrid.removeAllViews() themeCards.clear() + themeIdList.clear() // Update selected theme selectedTheme = currentTheme + focusedThemeId = currentTheme // Initially focus the selected theme + focusedIndex = -1 // Reset index - // Get all possible themes and their details - val allThemes = getThemes() + // Get all possible themes and their details, sorted for consistent order + 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) -> val isUnlocked = unlockedThemes.contains(themeId) val isSelected = themeId == selectedTheme + // Only add unlocked themes to the navigable list + if (isUnlocked) { + themeIdList.add(themeId) + } + val themeCard = createThemeCard(themeId, themeInfo, isUnlocked, isSelected) themeCards[themeId] = 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 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 val cardSize = resources.getDimensionPixelSize(R.dimen.theme_card_size) layoutParams = GridLayout.LayoutParams().apply { @@ -194,38 +212,10 @@ class ThemeSelector @JvmOverloads constructor( // Set up click listener only for unlocked themes if (isUnlocked) { card.setOnClickListener { - // Only trigger callback if this isn't already the selected theme - if (themeId != selectedTheme) { - // Update previously selected card - themeCards[selectedTheme]?.let { prevCard -> - 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) - } + // Clicking directly selects the theme + focusedThemeId = themeId + focusedIndex = themeIdList.indexOf(themeId) + confirmSelection() // Directly confirm click selection } } @@ -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 + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 9b2b84a..a76a8ba 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -472,22 +472,7 @@ - - - - - - +