diff --git a/Blackjack/Blackjack/Engine/GameState.swift b/Blackjack/Blackjack/Engine/GameState.swift index 772ed42..d882d80 100644 --- a/Blackjack/Blackjack/Engine/GameState.swift +++ b/Blackjack/Blackjack/Engine/GameState.swift @@ -813,7 +813,13 @@ final class GameState: CasinoGameState { let cardAppearDelay = settings.showAnimations ? animationDuration * 0.15 : 0 let remainingDelay = settings.showAnimations ? animationDuration * 0.85 : 0 - // Deal one card to each hand + // Brief delay to let SwiftUI render the split hands before dealing second cards + // This ensures both hand containers are visible before cards animate in + if settings.showAnimations { + try? await Task.sleep(for: .milliseconds(150)) + } + + // Deal one card to each hand (with full animation timing for each) if let card1 = engine.dealCard() { playerHands[activeHandIndex].cards.append(card1) sound.play(.cardDeal) @@ -1274,6 +1280,144 @@ final class GameState: CasinoGameState { performResetGame() // Note: newRound() is called by resetForNewSession() } + + // MARK: - Debug Helpers + + // ═══════════════════════════════════════════════════════════════════════════════ + // DEBUG TESTING UTILITIES + // ═══════════════════════════════════════════════════════════════════════════════ + // + // These methods are only available in DEBUG builds and provide ways to test + // specific game scenarios that are difficult to trigger with random card deals. + // + // ACCESS: + // - Settings sheet → scroll to bottom → "DEBUG" section (orange) + // - Only visible in DEBUG builds (not in Release/App Store builds) + // + // ADDING NEW DEBUG SCENARIOS: + // 1. Add a new async function below following the pattern of `debugDealWithPair()` + // 2. Add a corresponding button in SettingsView.swift inside the #if DEBUG block + // 3. Use `triggerDebugDeal(state:)` pattern to dismiss sheet before executing + // + // ANIMATION TIMING NOTES: + // - Always add 100ms delay after phase changes to let SwiftUI render containers + // - Use `cardAppearDelay` (15% of animation) before updating visible counts + // - Use `remainingDelay` (85% of animation) before dealing next card + // - For splits, add 150ms delay after creating hands before dealing second cards + // + // ═══════════════════════════════════════════════════════════════════════════════ + + #if DEBUG + + /// Forces a deal with a splittable pair for testing split hand scrolling and animations. + /// + /// This debug function deals a pair of 8s to the player (a classic split scenario) + /// with a dealer showing 6 (favorable split situation). Use this to test: + /// - Split hand scrolling behavior + /// - Card dealing animations for split hands + /// - PlayerHandsContainer centering with multiple hands + /// + /// ## Usage + /// Triggered via Settings → DEBUG → "Deal Splittable Pair (8s)" + /// + /// ## Dealt Cards + /// - Player: 8♥, 8♠ (pair, can split) + /// - Dealer: 6♦ (up), 10♣ (hole) + /// + /// ## Notes + /// - Auto-places minimum bet if none exists + /// - Must be in betting phase to work + /// - Includes proper animation timing delays + func debugDealWithPair() async { + Design.debugLog("🧪 Debug deal started - phase: \(currentPhase), bet: \(currentBet)") + + // Auto-place minimum bet if none exists + if currentBet < settings.minBet { + currentBet = settings.minBet + Design.debugLog("🧪 Auto-placed min bet: \(currentBet)") + } + guard currentPhase == .betting else { + Design.debugLog("🧪 Debug deal failed - not in betting phase (phase: \(currentPhase))") + return + } + + Design.debugLog("🧪 Starting debug deal with pair of 8s") + currentPhase = .dealing + dealerHand = BlackjackHand() + activeHandIndex = 0 + insuranceBet = 0 + + // Reset visible card counts + playerHandsVisibleCardCount = [0] + dealerVisibleCardCount = 0 + + // Brief delay to let PlayerHandsContainer appear before cards fly in + // (fixes race condition where first card animation is missed) + if settings.showAnimations { + try? await Task.sleep(for: .milliseconds(100)) + } + + // Create a pair of 8s (classic split scenario) with dealer showing 6 + let card1 = Card(suit: .hearts, rank: .eight) + let card2 = Card(suit: .spades, rank: .eight) + let dealerCard1 = Card(suit: .diamonds, rank: .six) + let dealerCard2 = Card(suit: .clubs, rank: .ten) + + playerHands = [BlackjackHand(cards: [], bet: currentBet)] + + // Animation timing (matches deal() function) + let animationDuration = Design.Animation.springDuration * settings.dealingSpeed + let cardAppearDelay = settings.showAnimations ? animationDuration * 0.15 : 0 + let remainingDelay = settings.showAnimations ? animationDuration * 0.85 : 0 + + // Deal player card 1 + playerHands[0].cards.append(card1) + sound.play(.cardDeal) + if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } + playerHandsVisibleCardCount[0] += 1 + if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) } + + // Deal dealer card 1 (face up) + dealerHand.cards.append(dealerCard1) + sound.play(.cardDeal) + if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } + dealerVisibleCardCount += 1 + if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) } + + // Deal player card 2 (matching rank for split) + playerHands[0].cards.append(card2) + sound.play(.cardDeal) + if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } + playerHandsVisibleCardCount[0] += 1 + if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) } + + // Deal dealer hole card (face down) + dealerHand.cards.append(dealerCard2) + sound.play(.cardDeal) + if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } + dealerVisibleCardCount += 1 + if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) } + + currentPhase = .playerTurn(handIndex: 0) + Design.debugLog("🧪 Debug deal complete - pair of 8s, can split: \(canSplit)") + } + + // ───────────────────────────────────────────────────────────────────────────── + // ADD NEW DEBUG SCENARIOS BELOW + // ───────────────────────────────────────────────────────────────────────────── + // + // Example template for a new debug scenario: + // + // /// Description of what this tests. + // func debugDealWithBlackjack() async { + // Design.debugLog("🧪 Debug blackjack started") + // // ... implementation following the pattern above + // } + // + // Then add a button in SettingsView.swift's DEBUG section. + // ───────────────────────────────────────────────────────────────────────────── + + #endif } diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index c324ffd..305a396 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -2268,6 +2268,9 @@ } } } + }, + "Deal Splittable Pair (8s)" : { + }, "DEALER" : { "localizations" : { diff --git a/Blackjack/Blackjack/Views/Sheets/SettingsView.swift b/Blackjack/Blackjack/Views/Sheets/SettingsView.swift index 99343b2..92627f8 100644 --- a/Blackjack/Blackjack/Views/Sheets/SettingsView.swift +++ b/Blackjack/Blackjack/Views/Sheets/SettingsView.swift @@ -26,6 +26,36 @@ struct SettingsView: View { /// Accent color for settings components private let accent = Color.Sheet.accent + // ═══════════════════════════════════════════════════════════════════════════════ + // DEBUG HELPERS + // ═══════════════════════════════════════════════════════════════════════════════ + // + // These methods trigger debug scenarios from GameState. + // The pattern is: dismiss sheet → wait for animation → call debug function + // + // TO ADD A NEW DEBUG TRIGGER: + // 1. Add a new trigger function below following the pattern + // 2. Add a corresponding button in the DEBUG SheetSection in body + // 3. The debug function itself lives in GameState.swift + // + // ═══════════════════════════════════════════════════════════════════════════════ + + #if DEBUG + /// Triggers the debug deal with splittable pair after dismissing the sheet. + /// Must dismiss first because the deal needs the main game view visible. + private func triggerDebugDeal(state: GameState) { + dismiss() + Task { @MainActor in + // Wait for sheet dismiss animation to complete + try? await Task.sleep(for: .milliseconds(500)) + await state.debugDealWithPair() + } + } + + // Add new trigger functions here following the same pattern: + // private func triggerDebugBlackjack(state: GameState) { ... } + #endif + var body: some View { SheetContainerView( title: String(localized: "Settings"), @@ -408,6 +438,38 @@ struct SettingsView: View { .padding(.top, Design.Spacing.xSmall) } + // ───────────────────────────────────────────────────────────── + // DEBUG SECTION - Only visible in DEBUG builds + // Add new debug buttons here. Each button should call a + // trigger function that dismisses the sheet first, then + // calls the corresponding debug function in GameState. + // ───────────────────────────────────────────────────────────── + #if DEBUG + if let state = gameState { + SheetSection(title: "DEBUG", icon: "ant.fill") { + // Split Testing - deals a pair of 8s + Button { + triggerDebugDeal(state: state) + } label: { + HStack { + Text("Deal Splittable Pair (8s)") + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.orange) + Spacer() + Image(systemName: "rectangle.split.2x1") + .font(.system(size: Design.BaseFontSize.large)) + .foregroundStyle(.orange) + } + .frame(minHeight: CasinoDesign.Size.actionRowMinHeight) + } + + // Add new debug buttons here: + // Divider().background(Color.orange.opacity(Design.Opacity.hint)) + // Button { triggerDebugBlackjack(state: state) } label: { ... } + } + } + #endif + // 13. Version info Text(appVersionString) .font(.system(size: Design.BaseFontSize.small)) diff --git a/Blackjack/Blackjack/Views/Table/DealerHandView.swift b/Blackjack/Blackjack/Views/Table/DealerHandView.swift index 78c53bd..8f49759 100644 --- a/Blackjack/Blackjack/Views/Table/DealerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/DealerHandView.swift @@ -22,17 +22,28 @@ struct DealerHandView: View { @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize - /// The value to display in the badge (based on visible cards and hole card state). - private var displayValue: Int? { + /// The value text to display in the badge (based on visible cards and hole card state). + /// Shows soft values like "7/17" for consistency with player hand display. + private var displayValueText: String? { guard !hand.cards.isEmpty && visibleCardCount > 0 else { return nil } if showHoleCard { // Hole card revealed - calculate value from visible cards let visibleCards = Array(hand.cards.prefix(visibleCardCount)) - return BlackjackHand.bestValue(for: visibleCards) + let (hardValue, softValue) = BlackjackHand.calculateValues(for: visibleCards) + let hasSoftAce = BlackjackHand.hasSoftAce(for: visibleCards) + + // When soft value is 21, there's no ambiguity - just show 21 + if softValue == 21 { + return "21" + } else if hasSoftAce { + return "\(hardValue)/\(softValue)" + } else { + return "\(BlackjackHand.bestValue(for: visibleCards))" + } } else { // Hole card hidden - show only the first (face-up) card's value - return hand.cards[0].blackjackValue + return "\(hand.cards[0].blackjackValue)" } } @@ -41,7 +52,7 @@ struct DealerHandView: View { // Label and value badge HandLabelView( title: String(localized: "DEALER"), - value: displayValue, + valueText: displayValueText, badgeColor: Color.Hand.dealer ) .animation(nil, value: visibleCardCount) diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift index 115f0cf..d2830c9 100644 --- a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift @@ -61,7 +61,15 @@ struct PlayerHandView: View { let isBusted = hardValue > 21 // Show value like hand.valueDisplay does (e.g., "8/18" for soft hands) - let valueText = hasSoftAce ? "\(hardValue)/\(softValue)" : "\(displayValue)" + // When soft value is 21, there's no ambiguity - just show 21 + let valueText: String + if softValue == 21 { + valueText = "21" + } else if hasSoftAce { + valueText = "\(hardValue)/\(softValue)" + } else { + valueText = "\(displayValue)" + } return (valueText, displayValue, isBusted) } diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift b/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift index 84d3b09..95105e2 100644 --- a/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift +++ b/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift @@ -80,9 +80,9 @@ struct PlayerHandsContainer: View { ScrollView(.horizontal, showsIndicators: false) { handsContent .padding(.horizontal, Design.Spacing.xxLarge) - // Ensure minimum width to fill viewport, centering smaller content via layout - // This avoids scroll anchor re-centering which doesn't animate - .containerRelativeFrame(.horizontal, alignment: .center) + // Only use containerRelativeFrame for single hand (centering) + // For multiple hands, allow natural scrolling + .modifier(CenterSingleHandModifier(isSingleHand: hands.count == 1)) .scrollTargetLayout() } .scrollClipDisabled() @@ -113,6 +113,23 @@ struct PlayerHandsContainer: View { } } +// MARK: - Centering Modifier + +/// Conditionally applies containerRelativeFrame for centering single hands. +/// For multiple hands, allows natural content width for scrolling. +private struct CenterSingleHandModifier: ViewModifier { + let isSingleHand: Bool + + func body(content: Content) -> some View { + if isSingleHand { + content + .containerRelativeFrame(.horizontal, alignment: .center) + } else { + content + } + } +} + // MARK: - Previews #Preview("Single Hand") {