diff --git a/Baccarat.xcodeproj/project.pbxproj b/Baccarat.xcodeproj/project.pbxproj index c3c74c1..6eed45e 100644 --- a/Baccarat.xcodeproj/project.pbxproj +++ b/Baccarat.xcodeproj/project.pbxproj @@ -514,8 +514,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -548,8 +548,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Blackjack/Engine/GameState.swift b/Blackjack/Engine/GameState.swift index 4b39121..470d56f 100644 --- a/Blackjack/Engine/GameState.swift +++ b/Blackjack/Engine/GameState.swift @@ -206,7 +206,7 @@ final class GameState { SavedRoundResult( date: Date(), mainResult: result.mainHandResult.saveName, - hadSplit: result.splitHandResult != nil, + hadSplit: result.hadSplit, totalWinnings: result.totalWinnings ) } @@ -628,10 +628,10 @@ final class GameState { bustCount += 1 } - // Create round result + // Create round result with all hand results + let allHandResults = playerHands.map { $0.result ?? .lose } lastRoundResult = RoundResult( - mainHandResult: playerHands[0].result ?? .lose, - splitHandResult: playerHands.count > 1 ? playerHands[1].result : nil, + handResults: allHandResults, insuranceResult: insuranceBet > 0 ? (dealerHand.isBlackjack ? .insuranceWin : .insuranceLose) : nil, totalWinnings: roundWinnings, wasBlackjack: wasBlackjack diff --git a/Blackjack/Models/GameResult.swift b/Blackjack/Models/GameResult.swift index 2bd8f69..529a674 100644 --- a/Blackjack/Models/GameResult.swift +++ b/Blackjack/Models/GameResult.swift @@ -66,10 +66,31 @@ enum HandResult: Equatable { /// Overall game result for the round. struct RoundResult: Equatable { - let mainHandResult: HandResult - let splitHandResult: HandResult? + /// Results for all player hands (index 0 = Hand 1, index 1 = Hand 2, etc.) + let handResults: [HandResult] let insuranceResult: HandResult? let totalWinnings: Int let wasBlackjack: Bool + + /// The main/best result for display purposes (first hand, or best if split) + var mainHandResult: HandResult { + // Return the best result for the headline + if wasBlackjack { return .blackjack } + if handResults.contains(.win) { return .win } + if handResults.contains(.push) { return .push } + if handResults.contains(.surrender) { return .surrender } + if handResults.allSatisfy({ $0 == .bust }) { return .bust } + return handResults.first ?? .lose + } + + /// Whether this round had split hands + var hadSplit: Bool { + handResults.count > 1 + } + + /// Legacy accessor for backwards compatibility + var splitHandResult: HandResult? { + handResults.count > 1 ? handResults[1] : nil + } } diff --git a/Blackjack/Models/Hand.swift b/Blackjack/Models/Hand.swift index 4a6d278..0ec8b1b 100644 --- a/Blackjack/Models/Hand.swift +++ b/Blackjack/Models/Hand.swift @@ -61,8 +61,9 @@ struct BlackjackHand: Identifiable, Equatable { } /// Whether this hand can hit. + /// Note: Standard Blackjack has NO card limit - you can hit until you bust or stand. var canHit: Bool { - !isBusted && !isStanding && !isBlackjack && cards.count < 5 + !isBusted && !isStanding && !isBlackjack } /// Calculates both hard and soft values. diff --git a/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Resources/Localizable.xcstrings index 5cce153..25b8905 100644 --- a/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Resources/Localizable.xcstrings @@ -745,6 +745,28 @@ "comment" : "Description of a deck count option when the user selects 4 decks.", "isCommentAutoGenerated" : true }, + "Cost: $%lld (half your bet)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cost: $%lld (half your bet)" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Costo: $%lld (mitad de tu apuesta)" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coût: %lld$ (la moitié de votre mise)" + } + } + } + }, "Costs half your original bet." : { "comment" : "Description of the cost of insurance in the rules help view.", "isCommentAutoGenerated" : true @@ -1316,6 +1338,10 @@ } } }, + "Hint" : { + "comment" : "A general label for a gameplay hint.", + "isCommentAutoGenerated" : true + }, "Hint: %@" : { "localizations" : { "en" : { @@ -1750,6 +1776,7 @@ } }, "No" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1798,6 +1825,28 @@ }, "No surrender option." : { + }, + "No Thanks" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Thanks" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "No, gracias" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non merci" + } + } + } }, "Objective" : { "localizations" : { @@ -2316,6 +2365,7 @@ "isCommentAutoGenerated" : true }, "Split Hand" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Blackjack/Theme/DesignConstants.swift b/Blackjack/Theme/DesignConstants.swift index 66e1893..b59f1fc 100644 --- a/Blackjack/Theme/DesignConstants.swift +++ b/Blackjack/Theme/DesignConstants.swift @@ -118,6 +118,7 @@ extension Color { // MARK: - Modal Colors enum Modal { + static let background = Color(red: 0.12, green: 0.18, blue: 0.25) static let backgroundLight = Color(red: 0.15, green: 0.2, blue: 0.3) static let backgroundDark = Color(red: 0.1, green: 0.15, blue: 0.25) } diff --git a/Blackjack/Views/BettingZoneView.swift b/Blackjack/Views/BettingZoneView.swift new file mode 100644 index 0000000..658dde9 --- /dev/null +++ b/Blackjack/Views/BettingZoneView.swift @@ -0,0 +1,106 @@ +// +// BettingZoneView.swift +// Blackjack +// +// The betting area where players place their bets. +// + +import SwiftUI +import CasinoKit + +struct BettingZoneView: View { + let betAmount: Int + let minBet: Int + let maxBet: Int + let onTap: () -> Void + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.large + + private var isAtMax: Bool { + betAmount >= maxBet + } + + var body: some View { + Button(action: onTap) { + ZStack { + // Background + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .fill(Color.BettingZone.main) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .strokeBorder(Color.BettingZone.mainBorder, lineWidth: Design.LineWidth.medium) + ) + + // Content + if betAmount > 0 { + // Show chip with amount + ChipOnTableView(amount: betAmount, showMax: isAtMax) + } else { + // Empty state + VStack(spacing: Design.Spacing.small) { + Text(String(localized: "TAP TO BET")) + .font(.system(size: labelFontSize, weight: .bold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + HStack(spacing: Design.Spacing.medium) { + Text(String(localized: "Min: $\(minBet)")) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + + Text(String(localized: "Max: $\(maxBet.formatted())")) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + } + } + } + } + .frame(maxWidth: .infinity) + .frame(height: Design.Size.bettingZoneHeight) + } + .buttonStyle(.plain) + .accessibilityLabel(betAmount > 0 ? "$\(betAmount) bet" + (isAtMax ? ", maximum" : "") : "Place bet") + .accessibilityHint("Double tap to add chips") + } +} + +// MARK: - Previews + +#Preview("Empty") { + ZStack { + Color.Table.felt.ignoresSafeArea() + BettingZoneView( + betAmount: 0, + minBet: 10, + maxBet: 1000, + onTap: {} + ) + .padding() + } +} + +#Preview("With Bet") { + ZStack { + Color.Table.felt.ignoresSafeArea() + BettingZoneView( + betAmount: 250, + minBet: 10, + maxBet: 1000, + onTap: {} + ) + .padding() + } +} + +#Preview("Max Bet") { + ZStack { + Color.Table.felt.ignoresSafeArea() + BettingZoneView( + betAmount: 1000, + minBet: 10, + maxBet: 1000, + onTap: {} + ) + .padding() + } +} + diff --git a/Blackjack/Views/BlackjackTableView.swift b/Blackjack/Views/BlackjackTableView.swift index c6d32b3..e07575b 100644 --- a/Blackjack/Views/BlackjackTableView.swift +++ b/Blackjack/Views/BlackjackTableView.swift @@ -39,17 +39,6 @@ struct BlackjackTableView: View { Spacer() - // Insurance zone (when offered) - if state.currentPhase == .insurance { - InsuranceZoneView( - betAmount: state.currentBet / 2, - balance: state.balance, - onTake: { Task { await state.takeInsurance() } }, - onDecline: { state.declineInsurance() } - ) - .transition(.scale.combined(with: .opacity)) - } - // Player hands area PlayerHandsView( hands: state.playerHands, @@ -145,508 +134,12 @@ struct BlackjackTableView: View { } } -// MARK: - Dealer Hand View +// MARK: - Previews -struct DealerHandView: View { - let hand: BlackjackHand - let showHoleCard: Bool - let showCardCount: Bool - let cardWidth: CGFloat - let cardSpacing: CGFloat - - @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium - - var body: some View { - VStack(spacing: Design.Spacing.small) { - // Label and value - HStack(spacing: Design.Spacing.small) { - Text(String(localized: "DEALER")) - .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - - // Show value: always show if hole card visible, or show single card value in European mode - if !hand.cards.isEmpty { - if showHoleCard { - ValueBadge(value: hand.value, color: Color.Hand.dealer) - } else if hand.cards.count == 1 { - // European mode: show single visible card value - ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer) - } - } - } - - // Cards - HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { - if hand.cards.isEmpty { - CardPlaceholderView(width: cardWidth) - CardPlaceholderView(width: cardWidth) - } else { - ForEach(hand.cards.indices, id: \.self) { index in - let isFaceUp = index == 0 || showHoleCard - CardView( - card: hand.cards[index], - isFaceUp: isFaceUp, - cardWidth: cardWidth - ) - .overlay(alignment: .bottomLeading) { - if showCardCount && isFaceUp { - HiLoCountBadge(card: hand.cards[index]) - } - } - .zIndex(Double(index)) - } - - // Show placeholder for second card in European mode (no hole card) - if hand.cards.count == 1 && !showHoleCard { - CardPlaceholderView(width: cardWidth) - .opacity(Design.Opacity.medium) - } - } - } - - // Result badge - if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil { - Text(result) - .font(.system(size: labelFontSize, weight: .black)) - .foregroundStyle(handResultColor) - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.xSmall) - .background( - Capsule() - .fill(handResultColor.opacity(Design.Opacity.hint)) - ) - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(dealerAccessibilityLabel) - } - - private var handResultText: String? { - if hand.isBlackjack { - return String(localized: "BLACKJACK") - } - if hand.isBusted { - return String(localized: "BUST") - } - return nil - } - - private var handResultColor: Color { - if hand.isBlackjack { return .yellow } - if hand.isBusted { return .green } // Good for player - return .white - } - - private var dealerAccessibilityLabel: String { - if hand.cards.isEmpty { - return String(localized: "Dealer: No cards") - } - let visibleCards = showHoleCard ? hand.cards : [hand.cards[0]] - let cardsDescription = visibleCards.map { $0.accessibilityDescription }.joined(separator: ", ") - return String(localized: "Dealer: \(cardsDescription). Value: \(showHoleCard ? String(hand.value) : "hidden")") +#Preview { + ZStack { + Color.Table.felt.ignoresSafeArea() + Text("Use GameTableView for full preview") + .foregroundStyle(.white) } } - -// MARK: - Player Hands View - -struct PlayerHandsView: View { - let hands: [BlackjackHand] - let activeHandIndex: Int - let isPlayerTurn: Bool - let showCardCount: Bool - let cardWidth: CGFloat - let cardSpacing: CGFloat - - /// Adaptive card width based on number of hands (smaller cards for more splits) - private var adaptiveCardWidth: CGFloat { - switch hands.count { - case 1, 2: - return cardWidth - case 3: - return cardWidth * 0.90 - default: // 4+ hands - return cardWidth * 0.85 - } - } - - /// Adaptive spacing based on number of hands - private var adaptiveSpacing: CGFloat { - switch hands.count { - case 1, 2: - return Design.Spacing.xxLarge - case 3: - return Design.Spacing.large - default: // 4+ hands - return Design.Spacing.medium - } - } - - var body: some View { - HStack(spacing: adaptiveSpacing) { - // Display hands in reverse order (right to left play order) - // So hand 0 (played first) appears on the right - ForEach(hands.indices.reversed(), id: \.self) { index in - PlayerHandView( - hand: hands[index], - isActive: index == activeHandIndex && isPlayerTurn, - showCardCount: showCardCount, - // Hand numbers: rightmost is Hand 1, leftmost is Hand 2, etc. - handNumber: hands.count > 1 ? hands.count - index : nil, - cardWidth: adaptiveCardWidth, - cardSpacing: cardSpacing - ) - } - } - .frame(maxWidth: .infinity) // Center the hands horizontally - } -} - -struct PlayerHandView: View { - let hand: BlackjackHand - let isActive: Bool - let showCardCount: Bool - let handNumber: Int? - let cardWidth: CGFloat - let cardSpacing: CGFloat - - @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium - @ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.BaseFontSize.small - - var body: some View { - VStack(spacing: Design.Spacing.small) { - // Cards with container - uses dynamic sizing based on card count - HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { - if hand.cards.isEmpty { - CardPlaceholderView(width: cardWidth) - CardPlaceholderView(width: cardWidth) - } else { - ForEach(hand.cards.indices, id: \.self) { index in - CardView( - card: hand.cards[index], - isFaceUp: true, - cardWidth: cardWidth - ) - .overlay(alignment: .bottomLeading) { - if showCardCount { - HiLoCountBadge(card: hand.cards[index]) - } - } - .zIndex(Double(index)) - } - } - } - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.medium) - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(Color.Table.feltDark.opacity(Design.Opacity.light)) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .strokeBorder( - isActive ? Color.Hand.active : Color.white.opacity(Design.Opacity.hint), - lineWidth: isActive ? Design.LineWidth.thick : Design.LineWidth.thin - ) - ) - ) - .contentShape(Rectangle()) // Ensure tap area matches visual - .animation(.easeInOut(duration: Design.Animation.quick), value: isActive) - - // Hand info - HStack(spacing: Design.Spacing.small) { - if let number = handNumber { - Text(String(localized: "Hand \(number)")) - .font(.system(size: handNumberSize, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - if !hand.cards.isEmpty { - Text(hand.valueDisplay) - .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(valueColor) - } - - if hand.isDoubledDown { - Image(systemName: "xmark.circle.fill") - .font(.system(size: handNumberSize)) - .foregroundStyle(.purple) - } - } - - // Result badge - if let result = hand.result { - Text(result.displayText) - .font(.system(size: labelFontSize, weight: .black)) - .foregroundStyle(result.color) - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.xSmall) - .background( - Capsule() - .fill(result.color.opacity(Design.Opacity.hint)) - ) - } - - // Bet amount - if hand.bet > 0 { - HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: "dollarsign.circle.fill") - .foregroundStyle(.yellow) - Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))") - .font(.system(size: handNumberSize, weight: .bold, design: .rounded)) - .foregroundStyle(.yellow) - } - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(playerAccessibilityLabel) - } - - private var valueColor: Color { - if hand.isBlackjack { return .yellow } - if hand.isBusted { return .red } - if hand.value == 21 { return .green } - return .white - } - - private var playerAccessibilityLabel: String { - let cardsDescription = hand.cards.map { $0.accessibilityDescription }.joined(separator: ", ") - var label = String(localized: "Player hand: \(cardsDescription). Value: \(hand.valueDisplay)") - if let result = hand.result { - label += ". \(result.displayText)" - } - return label - } -} - -// MARK: - Betting Zone View - -struct BettingZoneView: View { - let betAmount: Int - let minBet: Int - let maxBet: Int - let onTap: () -> Void - - @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.large - - private var isAtMax: Bool { - betAmount >= maxBet - } - - var body: some View { - Button(action: onTap) { - ZStack { - // Background - RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .fill(Color.BettingZone.main) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .strokeBorder(Color.BettingZone.mainBorder, lineWidth: Design.LineWidth.medium) - ) - - // Content - if betAmount > 0 { - // Show chip with amount - ChipOnTableView(amount: betAmount, showMax: isAtMax) - } else { - // Empty state - VStack(spacing: Design.Spacing.small) { - Text(String(localized: "TAP TO BET")) - .font(.system(size: labelFontSize, weight: .bold)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - - HStack(spacing: Design.Spacing.medium) { - Text(String(localized: "Min: $\(minBet)")) - .font(.system(size: Design.BaseFontSize.small, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.light)) - - Text(String(localized: "Max: $\(maxBet.formatted())")) - .font(.system(size: Design.BaseFontSize.small, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.light)) - } - } - } - } - .frame(maxWidth: .infinity) - .frame(height: Design.Size.bettingZoneHeight) - } - .buttonStyle(.plain) - .accessibilityLabel(betAmount > 0 ? "$\(betAmount) bet" + (isAtMax ? ", maximum" : "") : "Place bet") - .accessibilityHint("Double tap to add chips") - } -} - -// MARK: - Insurance Zone View - -struct InsuranceZoneView: View { - let betAmount: Int - let balance: Int - let onTake: () -> Void - let onDecline: () -> Void - - @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium - @ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = Design.BaseFontSize.body - - var body: some View { - VStack(spacing: Design.Spacing.medium) { - Text(String(localized: "INSURANCE?")) - .font(.system(size: labelFontSize, weight: .bold)) - .foregroundStyle(.yellow) - - Text(String(localized: "Dealer showing Ace")) - .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - - HStack(spacing: Design.Spacing.large) { - Button(action: onDecline) { - Text(String(localized: "No")) - .font(.system(size: buttonFontSize, weight: .bold)) - .foregroundStyle(.white) - .padding(.horizontal, Design.Spacing.xxLarge) - .padding(.vertical, Design.Spacing.medium) - .background( - Capsule() - .fill(Color.Button.surrender) - ) - } - - if balance >= betAmount { - Button(action: onTake) { - Text(String(localized: "Yes ($\(betAmount))")) - .font(.system(size: buttonFontSize, weight: .bold)) - .foregroundStyle(.black) - .padding(.horizontal, Design.Spacing.xxLarge) - .padding(.vertical, Design.Spacing.medium) - .background( - Capsule() - .fill(Color.Button.insurance) - ) - } - } - } - } - .padding(Design.Spacing.large) - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .fill(Color.BettingZone.insurance.opacity(Design.Opacity.heavy)) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .strokeBorder(Color.BettingZone.insuranceBorder, lineWidth: Design.LineWidth.medium) - ) - ) - } -} - -// MARK: - Betting Hint View - -/// Shows betting recommendations based on the current count. -struct BettingHintView: View { - let hint: String - let trueCount: Double - - private var hintColor: Color { - let tc = Int(trueCount.rounded()) - if tc >= 2 { - return .green // Player advantage - bet more - } else if tc <= -1 { - return .red // House advantage - bet less - } else { - return .yellow // Neutral - } - } - - private var icon: String { - let tc = Int(trueCount.rounded()) - if tc >= 2 { - return "arrow.up.circle.fill" // Increase bet - } else if tc <= -1 { - return "arrow.down.circle.fill" // Decrease bet - } else { - return "equal.circle.fill" // Neutral - } - } - - var body: some View { - HStack(spacing: Design.Spacing.small) { - Image(systemName: icon) - .foregroundStyle(hintColor) - Text(hint) - .font(.system(size: Design.BaseFontSize.small, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.strong)) - } - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background( - Capsule() - .fill(Color.black.opacity(Design.Opacity.light)) - .overlay( - Capsule() - .strokeBorder(hintColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) - ) - ) - .accessibilityElement(children: .ignore) - .accessibilityLabel(String(localized: "Betting Hint")) - .accessibilityValue(hint) - } -} - -// MARK: - Hint View - -struct HintView: View { - let hint: String - - var body: some View { - HStack(spacing: Design.Spacing.small) { - Image(systemName: "lightbulb.fill") - .foregroundStyle(.yellow) - Text(String(localized: "Hint: \(hint)")) - .font(.system(size: Design.BaseFontSize.small, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.strong)) - } - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background( - Capsule() - .fill(Color.black.opacity(Design.Opacity.light)) - ) - } -} - -// MARK: - Hi-Lo Count Badge - -/// A small badge showing the Hi-Lo counting value of a card. -struct HiLoCountBadge: View { - let card: Card - - var body: some View { - Text(card.hiLoDisplayText) - .font(.system(size: Design.BaseFontSize.xxSmall, weight: .bold, design: .rounded)) - .foregroundStyle(badgeTextColor) - .padding(.horizontal, Design.Spacing.xSmall) - .padding(.vertical, Design.Spacing.xxxSmall) - .background( - Capsule() - .fill(badgeBackgroundColor) - ) - .offset(x: -Design.Spacing.xSmall, y: Design.Spacing.xSmall) - } - - private var badgeBackgroundColor: Color { - switch card.hiLoValue { - case 1: return .green // Low cards = positive for player - case -1: return .red // High cards = negative for player - default: return .gray // Neutral - } - } - - private var badgeTextColor: Color { - .white - } -} - -// MARK: - Card Accessibility Extension - -extension Card { - var accessibilityDescription: String { - "\(rank.accessibilityName) of \(suit.accessibilityName)" - } -} - diff --git a/Blackjack/Views/DealerHandView.swift b/Blackjack/Views/DealerHandView.swift new file mode 100644 index 0000000..10bb127 --- /dev/null +++ b/Blackjack/Views/DealerHandView.swift @@ -0,0 +1,157 @@ +// +// DealerHandView.swift +// Blackjack +// +// Displays the dealer's hand with cards and value. +// + +import SwiftUI +import CasinoKit + +struct DealerHandView: View { + let hand: BlackjackHand + let showHoleCard: Bool + let showCardCount: Bool + let cardWidth: CGFloat + let cardSpacing: CGFloat + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium + + var body: some View { + VStack(spacing: Design.Spacing.small) { + // Label and value + HStack(spacing: Design.Spacing.small) { + Text(String(localized: "DEALER")) + .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + // Show value: always show if hole card visible, or show single card value in European mode + if !hand.cards.isEmpty { + if showHoleCard { + ValueBadge(value: hand.value, color: Color.Hand.dealer) + } else if hand.cards.count == 1 { + // European mode: show single visible card value + ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer) + } + } + } + + // Cards + HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { + if hand.cards.isEmpty { + CardPlaceholderView(width: cardWidth) + CardPlaceholderView(width: cardWidth) + } else { + ForEach(hand.cards.indices, id: \.self) { index in + let isFaceUp = index == 0 || showHoleCard + CardView( + card: hand.cards[index], + isFaceUp: isFaceUp, + cardWidth: cardWidth + ) + .overlay(alignment: .bottomLeading) { + if showCardCount && isFaceUp { + HiLoCountBadge(card: hand.cards[index]) + } + } + .zIndex(Double(index)) + } + + // Show placeholder for second card in European mode (no hole card) + if hand.cards.count == 1 && !showHoleCard { + CardPlaceholderView(width: cardWidth) + .opacity(Design.Opacity.medium) + } + } + } + + // Result badge + if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil { + Text(result) + .font(.system(size: labelFontSize, weight: .black)) + .foregroundStyle(handResultColor) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.xSmall) + .background( + Capsule() + .fill(handResultColor.opacity(Design.Opacity.hint)) + ) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(dealerAccessibilityLabel) + } + + // MARK: - Computed Properties + + private var handResultText: String? { + if hand.isBlackjack { + return String(localized: "BLACKJACK") + } + if hand.isBusted { + return String(localized: "BUST") + } + return nil + } + + private var handResultColor: Color { + if hand.isBlackjack { return .yellow } + if hand.isBusted { return .green } // Good for player + return .white + } + + private var dealerAccessibilityLabel: String { + if hand.cards.isEmpty { + return String(localized: "Dealer: No cards") + } + let visibleCards = showHoleCard ? hand.cards : [hand.cards[0]] + let cardsDescription = visibleCards.map { $0.accessibilityDescription }.joined(separator: ", ") + return String(localized: "Dealer: \(cardsDescription). Value: \(showHoleCard ? String(hand.value) : "hidden")") + } +} + +#Preview("Empty Hand") { + ZStack { + Color.Table.felt.ignoresSafeArea() + DealerHandView( + hand: BlackjackHand(), + showHoleCard: false, + showCardCount: false, + cardWidth: 60, + cardSpacing: -20 + ) + } +} + +#Preview("Two Cards - Hole Hidden") { + ZStack { + Color.Table.felt.ignoresSafeArea() + DealerHandView( + hand: BlackjackHand(cards: [ + Card(suit: .spades, rank: .ace), + Card(suit: .hearts, rank: .king) + ]), + showHoleCard: false, + showCardCount: false, + cardWidth: 60, + cardSpacing: -20 + ) + } +} + +#Preview("Blackjack - Revealed") { + ZStack { + Color.Table.felt.ignoresSafeArea() + DealerHandView( + hand: BlackjackHand(cards: [ + Card(suit: .spades, rank: .ace), + Card(suit: .hearts, rank: .king) + ]), + showHoleCard: true, + showCardCount: true, + cardWidth: 60, + cardSpacing: -20 + ) + } +} + diff --git a/Blackjack/Views/GameTableView.swift b/Blackjack/Views/GameTableView.swift index 77009a3..6faff85 100644 --- a/Blackjack/Views/GameTableView.swift +++ b/Blackjack/Views/GameTableView.swift @@ -111,15 +111,18 @@ struct GameTableView: View { Spacer() - // Chip selector + // Chip selector - only interactive during betting phase + // During gameplay, show chips as they were (balance + currentBet) but dimmed ChipSelectorView( selectedChip: $selectedChip, - balance: state.balance, - currentBet: state.currentBet, + balance: state.currentPhase == .betting ? state.balance : (state.balance + state.currentBet), + currentBet: state.currentPhase == .betting ? state.currentBet : 0, maxBet: state.settings.maxBet ) .frame(maxWidth: maxContentWidth) .padding(.bottom, Design.Spacing.small) + .opacity(state.currentPhase == .betting ? 1.0 : Design.Opacity.medium) + .allowsHitTesting(state.currentPhase == .betting) // Disable interaction when not betting // Action buttons ActionButtonsView(state: state) @@ -128,6 +131,17 @@ struct GameTableView: View { } .frame(maxWidth: .infinity) + // Insurance popup overlay (covers entire screen) + if state.currentPhase == .insurance { + InsurancePopupView( + betAmount: state.currentBet / 2, + balance: state.balance, + onTake: { Task { await state.takeInsurance() } }, + onDecline: { state.declineInsurance() } + ) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + // Result banner overlay if state.showResultBanner, let result = state.lastRoundResult { ResultBannerView( diff --git a/Blackjack/Views/HiLoCountBadge.swift b/Blackjack/Views/HiLoCountBadge.swift new file mode 100644 index 0000000..57ce7a8 --- /dev/null +++ b/Blackjack/Views/HiLoCountBadge.swift @@ -0,0 +1,81 @@ +// +// HiLoCountBadge.swift +// Blackjack +// +// Badge showing the Hi-Lo counting value of a card. +// + +import SwiftUI +import CasinoKit + +/// A small badge showing the Hi-Lo counting value of a card. +struct HiLoCountBadge: View { + let card: Card + + var body: some View { + Text(card.hiLoDisplayText) + .font(.system(size: Design.BaseFontSize.xxSmall, weight: .bold, design: .rounded)) + .foregroundStyle(badgeTextColor) + .padding(.horizontal, Design.Spacing.xSmall) + .padding(.vertical, Design.Spacing.xxxSmall) + .background( + Capsule() + .fill(badgeBackgroundColor) + ) + .offset(x: -Design.Spacing.xSmall, y: Design.Spacing.xSmall) + } + + private var badgeBackgroundColor: Color { + switch card.hiLoValue { + case 1: return .green // Low cards = positive for player + case -1: return .red // High cards = negative for player + default: return .gray // Neutral + } + } + + private var badgeTextColor: Color { + .white + } +} + +// MARK: - Card Accessibility Extension + +extension Card { + /// Accessibility description for VoiceOver. + var accessibilityDescription: String { + "\(rank.accessibilityName) of \(suit.accessibilityName)" + } +} + +// MARK: - Previews + +#Preview("Low Card (+1)") { + ZStack { + Color.Table.felt.ignoresSafeArea() + CardView(card: Card(suit: .hearts, rank: .five), isFaceUp: true, cardWidth: 70) + .overlay(alignment: .bottomLeading) { + HiLoCountBadge(card: Card(suit: .hearts, rank: .five)) + } + } +} + +#Preview("High Card (-1)") { + ZStack { + Color.Table.felt.ignoresSafeArea() + CardView(card: Card(suit: .spades, rank: .king), isFaceUp: true, cardWidth: 70) + .overlay(alignment: .bottomLeading) { + HiLoCountBadge(card: Card(suit: .spades, rank: .king)) + } + } +} + +#Preview("Neutral Card (0)") { + ZStack { + Color.Table.felt.ignoresSafeArea() + CardView(card: Card(suit: .diamonds, rank: .seven), isFaceUp: true, cardWidth: 70) + .overlay(alignment: .bottomLeading) { + HiLoCountBadge(card: Card(suit: .diamonds, rank: .seven)) + } + } +} + diff --git a/Blackjack/Views/HintViews.swift b/Blackjack/Views/HintViews.swift new file mode 100644 index 0000000..7ef87e5 --- /dev/null +++ b/Blackjack/Views/HintViews.swift @@ -0,0 +1,126 @@ +// +// HintViews.swift +// Blackjack +// +// Views for displaying game hints and betting recommendations. +// + +import SwiftUI +import CasinoKit + +// MARK: - General Hint View + +/// Displays a gameplay hint (hit, stand, double, etc.) +struct HintView: View { + let hint: String + + var body: some View { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "lightbulb.fill") + .foregroundStyle(.yellow) + Text(String(localized: "Hint: \(hint)")) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background( + Capsule() + .fill(Color.black.opacity(Design.Opacity.light)) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Hint")) + .accessibilityValue(hint) + } +} + +// MARK: - Betting Hint View + +/// Shows betting recommendations based on the current card count. +struct BettingHintView: View { + let hint: String + let trueCount: Double + + private var hintColor: Color { + let tc = Int(trueCount.rounded()) + if tc >= 2 { + return .green // Player advantage - bet more + } else if tc <= -1 { + return .red // House advantage - bet less + } else { + return .yellow // Neutral + } + } + + private var icon: String { + let tc = Int(trueCount.rounded()) + if tc >= 2 { + return "arrow.up.circle.fill" // Increase bet + } else if tc <= -1 { + return "arrow.down.circle.fill" // Decrease bet + } else { + return "equal.circle.fill" // Neutral + } + } + + var body: some View { + HStack(spacing: Design.Spacing.small) { + Image(systemName: icon) + .foregroundStyle(hintColor) + Text(hint) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background( + Capsule() + .fill(Color.black.opacity(Design.Opacity.light)) + .overlay( + Capsule() + .strokeBorder(hintColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) + ) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Betting Hint")) + .accessibilityValue(hint) + } +} + +// MARK: - Previews + +#Preview("Game Hint - Hit") { + ZStack { + Color.Table.felt.ignoresSafeArea() + HintView(hint: "Hit") + } +} + +#Preview("Game Hint - Stand") { + ZStack { + Color.Table.felt.ignoresSafeArea() + HintView(hint: "Stand") + } +} + +#Preview("Betting Hint - Positive Count") { + ZStack { + Color.Table.felt.ignoresSafeArea() + BettingHintView(hint: "Bet 4x minimum", trueCount: 2.5) + } +} + +#Preview("Betting Hint - Negative Count") { + ZStack { + Color.Table.felt.ignoresSafeArea() + BettingHintView(hint: "Bet minimum", trueCount: -1.5) + } +} + +#Preview("Betting Hint - Neutral") { + ZStack { + Color.Table.felt.ignoresSafeArea() + BettingHintView(hint: "Bet minimum (neutral)", trueCount: 0.0) + } +} + diff --git a/Blackjack/Views/InsurancePopupView.swift b/Blackjack/Views/InsurancePopupView.swift new file mode 100644 index 0000000..b48ca13 --- /dev/null +++ b/Blackjack/Views/InsurancePopupView.swift @@ -0,0 +1,119 @@ +// +// InsurancePopupView.swift +// Blackjack +// +// Modal popup for insurance decision when dealer shows an Ace. +// + +import SwiftUI +import CasinoKit + +struct InsurancePopupView: View { + let betAmount: Int + let balance: Int + let onTake: () -> Void + let onDecline: () -> Void + + var body: some View { + ZStack { + // Dimmed background + Color.black.opacity(Design.Opacity.medium) + .ignoresSafeArea() + .onTapGesture { } // Prevent taps passing through + + // Popup card + VStack(spacing: Design.Spacing.large) { + // Icon + Image(systemName: "shield.fill") + .font(.system(size: Design.IconSize.xLarge)) + .foregroundStyle(.yellow) + + // Title + Text(String(localized: "INSURANCE?")) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) + .foregroundStyle(.white) + + // Subtitle + Text(String(localized: "Dealer showing Ace")) + .font(.system(size: Design.BaseFontSize.medium)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + // Cost info + Text(String(localized: "Cost: $\(betAmount) (half your bet)")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .padding(.bottom, Design.Spacing.small) + + // Buttons + HStack(spacing: Design.Spacing.large) { + // Decline button + Button(action: onDecline) { + Text(String(localized: "No Thanks")) + .font(.system(size: Design.BaseFontSize.medium, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, Design.Spacing.xLarge) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill(Color.red.opacity(Design.Opacity.heavy)) + ) + } + + // Accept button (only if can afford) + if balance >= betAmount { + Button(action: onTake) { + Text(String(localized: "Yes ($\(betAmount))")) + .font(.system(size: Design.BaseFontSize.medium, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xLarge) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color.Button.goldLight, Color.Button.goldDark], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + } + } + } + } + .padding(Design.Spacing.xLarge) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) + .fill(Color.Modal.background) + .shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusXLarge) + ) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) + .strokeBorder(Color.yellow.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + ) + } + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + } +} + +// MARK: - Previews + +#Preview("Can Afford") { + InsurancePopupView( + betAmount: 500, + balance: 4500, + onTake: {}, + onDecline: {} + ) +} + +#Preview("Cannot Afford") { + InsurancePopupView( + betAmount: 500, + balance: 200, + onTake: {}, + onDecline: {} + ) +} + diff --git a/Blackjack/Views/PlayerHandView.swift b/Blackjack/Views/PlayerHandView.swift new file mode 100644 index 0000000..f1e4923 --- /dev/null +++ b/Blackjack/Views/PlayerHandView.swift @@ -0,0 +1,247 @@ +// +// PlayerHandView.swift +// Blackjack +// +// Displays player hands in a horizontally scrollable container. +// + +import SwiftUI +import CasinoKit + +// MARK: - Player Hands Container + +/// Container for multiple player hands with horizontal scrolling. +struct PlayerHandsView: View { + let hands: [BlackjackHand] + let activeHandIndex: Int + let isPlayerTurn: Bool + let showCardCount: Bool + let cardWidth: CGFloat + let cardSpacing: CGFloat + + var body: some View { + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Design.Spacing.large) { + // Display hands in reverse order (right to left play order) + // Visual order: Hand 3, Hand 2, Hand 1 (left to right) + // Play order: Hand 1 played first (rightmost), then Hand 2, etc. + ForEach(hands.indices.reversed(), id: \.self) { index in + PlayerHandView( + hand: hands[index], + isActive: index == activeHandIndex && isPlayerTurn, + showCardCount: showCardCount, + // Hand numbers: rightmost (index 0) is Hand 1, played first + handNumber: hands.count > 1 ? index + 1 : nil, + cardWidth: cardWidth, + cardSpacing: cardSpacing + ) + .id(index) + } + } + .padding(.horizontal, Design.Spacing.medium) + .frame(minWidth: geometry.size.width) + } + .scrollClipDisabled() + .onChange(of: activeHandIndex) { _, newIndex in + withAnimation(.easeInOut(duration: Design.Animation.quick)) { + proxy.scrollTo(newIndex, anchor: .center) + } + } + .onAppear { + if hands.count > 1 { + proxy.scrollTo(activeHandIndex, anchor: .center) + } + } + } + } + .frame(height: 180) + } +} + +// MARK: - Single Player Hand + +/// Displays a single player hand with cards, value, and result. +struct PlayerHandView: View { + let hand: BlackjackHand + let isActive: Bool + let showCardCount: Bool + let handNumber: Int? + let cardWidth: CGFloat + let cardSpacing: CGFloat + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium + @ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.BaseFontSize.small + + var body: some View { + VStack(spacing: Design.Spacing.small) { + // Cards with container + HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { + if hand.cards.isEmpty { + CardPlaceholderView(width: cardWidth) + CardPlaceholderView(width: cardWidth) + } else { + ForEach(hand.cards.indices, id: \.self) { index in + CardView( + card: hand.cards[index], + isFaceUp: true, + cardWidth: cardWidth + ) + .overlay(alignment: .bottomLeading) { + if showCardCount { + HiLoCountBadge(card: hand.cards[index]) + } + } + .zIndex(Double(index)) + } + } + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.Table.feltDark.opacity(Design.Opacity.light)) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .strokeBorder( + isActive ? Color.Hand.active : Color.white.opacity(Design.Opacity.hint), + lineWidth: isActive ? Design.LineWidth.thick : Design.LineWidth.thin + ) + ) + ) + .contentShape(Rectangle()) + .animation(.easeInOut(duration: Design.Animation.quick), value: isActive) + + // Hand info + HStack(spacing: Design.Spacing.small) { + if let number = handNumber { + Text(String(localized: "Hand \(number)")) + .font(.system(size: handNumberSize, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + if !hand.cards.isEmpty { + Text(hand.valueDisplay) + .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(valueColor) + } + + if hand.isDoubledDown { + Image(systemName: "xmark.circle.fill") + .font(.system(size: handNumberSize)) + .foregroundStyle(.purple) + } + } + + // Result badge + if let result = hand.result { + Text(result.displayText) + .font(.system(size: labelFontSize, weight: .black)) + .foregroundStyle(result.color) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.xSmall) + .background( + Capsule() + .fill(result.color.opacity(Design.Opacity.hint)) + ) + } + + // Bet amount + if hand.bet > 0 { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "dollarsign.circle.fill") + .foregroundStyle(.yellow) + Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))") + .font(.system(size: handNumberSize, weight: .bold, design: .rounded)) + .foregroundStyle(.yellow) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(playerAccessibilityLabel) + } + + // MARK: - Computed Properties + + private var valueColor: Color { + if hand.isBlackjack { return .yellow } + if hand.isBusted { return .red } + if hand.value == 21 { return .green } + return .white + } + + private var playerAccessibilityLabel: String { + let cardsDescription = hand.cards.map { $0.accessibilityDescription }.joined(separator: ", ") + var label = String(localized: "Player hand: \(cardsDescription). Value: \(hand.valueDisplay)") + if let result = hand.result { + label += ". \(result.displayText)" + } + return label + } +} + +// MARK: - Previews + +#Preview("Single Hand - Empty") { + ZStack { + Color.Table.felt.ignoresSafeArea() + PlayerHandsView( + hands: [BlackjackHand()], + activeHandIndex: 0, + isPlayerTurn: true, + showCardCount: false, + cardWidth: 60, + cardSpacing: -20 + ) + } +} + +#Preview("Single Hand - Cards") { + ZStack { + Color.Table.felt.ignoresSafeArea() + PlayerHandsView( + hands: [BlackjackHand(cards: [ + Card(suit: .clubs, rank: .eight), + Card(suit: .hearts, rank: .nine) + ], bet: 100)], + activeHandIndex: 0, + isPlayerTurn: true, + showCardCount: false, + cardWidth: 60, + cardSpacing: -20 + ) + } +} + +#Preview("Split Hands") { + ZStack { + Color.Table.felt.ignoresSafeArea() + PlayerHandsView( + hands: [ + BlackjackHand(cards: [ + Card(suit: .clubs, rank: .eight), + Card(suit: .spades, rank: .jack) + ], bet: 100), + BlackjackHand(cards: [ + Card(suit: .hearts, rank: .eight), + Card(suit: .diamonds, rank: .five) + ], bet: 100), + BlackjackHand(cards: [ + Card(suit: .hearts, rank: .eight), + Card(suit: .diamonds, rank: .five) + ], bet: 100), + BlackjackHand(cards: [ + Card(suit: .hearts, rank: .eight), + Card(suit: .diamonds, rank: .five) + ], bet: 100) + ], + activeHandIndex: 1, + isPlayerTurn: true, + showCardCount: true, + cardWidth: 60, + cardSpacing: -20 + ) + } +} + diff --git a/Blackjack/Views/ResultBannerView.swift b/Blackjack/Views/ResultBannerView.swift index f74e561..dc8a394 100644 --- a/Blackjack/Views/ResultBannerView.swift +++ b/Blackjack/Views/ResultBannerView.swift @@ -68,19 +68,22 @@ struct ResultBannerView: View { .font(.system(size: amountFontSize, weight: .bold, design: .rounded)) .foregroundStyle(winningsColor) - // Breakdown + // Breakdown - all hands VStack(spacing: Design.Spacing.small) { - ResultRow(label: String(localized: "Main Hand"), result: result.mainHandResult) - - if let splitResult = result.splitHandResult { - ResultRow(label: String(localized: "Split Hand"), result: splitResult) + ForEach(result.handResults.indices, id: \.self) { index in + let handResult = result.handResults[index] + // Hand numbering: index 0 = Hand 1 (played first, displayed rightmost) + let handLabel = result.handResults.count > 1 + ? String(localized: "Hand \(index + 1)") + : String(localized: "Main Hand") + ResultRow(label: handLabel, result: handResult) } if let insuranceResult = result.insuranceResult { ResultRow(label: String(localized: "Insurance"), result: insuranceResult) } } - .padding() + .padding(Design.Spacing.medium) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) .fill(Color.white.opacity(Design.Opacity.subtle)) @@ -158,6 +161,7 @@ struct ResultBannerView: View { ) .shadow(color: mainResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge) .frame(maxWidth: Design.Size.maxModalWidth) + .padding(.horizontal, Design.Spacing.large) // Prevent clipping on sides .scaleEffect(showContent ? 1.0 : 0.8) .opacity(showContent ? 1.0 : 0) } @@ -201,11 +205,10 @@ struct ResultRow: View { } } -#Preview { +#Preview("Single Hand") { ResultBannerView( result: RoundResult( - mainHandResult: .blackjack, - splitHandResult: nil, + handResults: [.blackjack], insuranceResult: nil, totalWinnings: 150, wasBlackjack: true @@ -217,3 +220,18 @@ struct ResultRow: View { ) } +#Preview("Multiple Split Hands") { + ResultBannerView( + result: RoundResult( + handResults: [.bust, .win, .push], + insuranceResult: nil, + totalWinnings: 25, + wasBlackjack: false + ), + currentBalance: 1025, + minBet: 10, + onNewRound: {}, + onPlayAgain: {} + ) +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift index 0746a09..fca2c95 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift @@ -50,29 +50,37 @@ public struct ChipSelectorView: View { } public var body: some View { - ScrollView(.horizontal) { - HStack(spacing: CasinoDesign.Spacing.medium) { - ForEach(availableChips) { denomination in - Button { - selectedChip = denomination - } label: { - ChipView( - denomination: denomination, - size: CasinoDesign.Size.chipLarge, - isSelected: selectedChip == denomination, - theme: theme - ) + GeometryReader { geometry in + let chipCount = CGFloat(availableChips.count) + let totalChipsWidth = chipCount * CasinoDesign.Size.chipLarge + (chipCount - 1) * CasinoDesign.Spacing.medium + let fitsOnScreen = totalChipsWidth + CasinoDesign.Spacing.xLarge * 2 <= geometry.size.width + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: CasinoDesign.Spacing.medium) { + ForEach(availableChips) { denomination in + Button { + selectedChip = denomination + } label: { + ChipView( + denomination: denomination, + size: CasinoDesign.Size.chipLarge, + isSelected: selectedChip == denomination, + theme: theme + ) + } + .buttonStyle(.plain) + .opacity(canUseChip(denomination) ? 1.0 : CasinoDesign.Opacity.medium) + .disabled(!canUseChip(denomination)) } - .buttonStyle(.plain) - .opacity(canUseChip(denomination) ? 1.0 : CasinoDesign.Opacity.medium) - .disabled(!canUseChip(denomination)) } + .padding(.horizontal, CasinoDesign.Spacing.xLarge) + .padding(.vertical, CasinoDesign.Spacing.medium) + .frame(minWidth: geometry.size.width) // Center when content fits } - .padding(.horizontal, CasinoDesign.Spacing.large) - .padding(.vertical, CasinoDesign.Spacing.medium) // Extra padding for selection scale effect + .scrollClipDisabled() // Prevent harsh clipping during scroll/animation + .scrollBounceBehavior(fitsOnScreen ? .basedOnSize : .automatic) } - .scrollIndicators(.hidden) - .frame(maxWidth: .infinity) // Center the scroll content + .frame(height: CasinoDesign.Size.chipLarge + CasinoDesign.Spacing.medium * 2) .accessibilityElement(children: .contain) .accessibilityLabel(String(localized: "Chip selector", bundle: .module)) .accessibilityHint(String(localized: "Double tap a chip to select bet amount", bundle: .module))