// // BlackjackTableView.swift // Blackjack // // The main table layout showing dealer and player hands. // import SwiftUI import CasinoKit struct BlackjackTableView: View { @Bindable var state: GameState let onPlaceBet: () -> Void /// Whether to show Hi-Lo card count values on cards. var showCardCount: Bool { state.settings.showCardCount } // MARK: - Scaled Metrics @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium @ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge @ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small // MARK: - Layout private let cardWidth: CGFloat = Design.Size.cardWidth private let cardSpacing: CGFloat = Design.Size.cardOverlap var body: some View { VStack(spacing: Design.Spacing.large) { // Dealer area DealerHandView( hand: state.dealerHand, showHoleCard: shouldShowDealerHoleCard, showCardCount: showCardCount, cardWidth: cardWidth, cardSpacing: cardSpacing ) 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, activeHandIndex: state.activeHandIndex, isPlayerTurn: isPlayerTurn, showCardCount: showCardCount, cardWidth: cardWidth, cardSpacing: cardSpacing ) // Betting zone (when betting) if state.currentPhase == .betting { BettingZoneView( betAmount: state.currentBet, minBet: state.settings.minBet, maxBet: state.settings.maxBet, onTap: onPlaceBet ) .transition(.scale.combined(with: .opacity)) // Betting hint based on count (only when card counting enabled) if showCardCount, let bettingHint = bettingHint { BettingHintView(hint: bettingHint, trueCount: state.engine.trueCount) .transition(.opacity) } } // Hint (when enabled and player turn) if state.settings.showHints && isPlayerTurn, let hint = currentHint { HintView(hint: hint) .transition(.opacity) } } .padding(.horizontal, Design.Spacing.large) .padding(.vertical, Design.Spacing.medium) .animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase) } // MARK: - Computed Properties private var shouldShowDealerHoleCard: Bool { switch state.currentPhase { case .dealerTurn, .roundComplete: return true default: return false } } private var isPlayerTurn: Bool { if case .playerTurn = state.currentPhase { return true } return false } private var currentHint: String? { guard let hand = state.activeHand, let upCard = state.dealerUpCard else { return nil } // Use count-adjusted hints when card counting is enabled if showCardCount { return state.engine.getCountAdjustedHint(playerHand: hand, dealerUpCard: upCard) } return state.engine.getHint(playerHand: hand, dealerUpCard: upCard) } /// Betting recommendation based on the true count. private var bettingHint: String? { let tc = Int(state.engine.trueCount.rounded()) // Betting spread recommendations based on true count switch tc { case ...(-2): return String(localized: "Bet minimum or sit out") case -1: return String(localized: "Bet minimum") case 0: return String(localized: "Bet minimum (neutral)") case 1: return String(localized: "Bet 2x minimum") case 2: return String(localized: "Bet 4x minimum") case 3: return String(localized: "Bet 6x minimum") case 4: return String(localized: "Bet 8x minimum") case 5...: return String(localized: "Bet maximum!") default: return nil } } } // MARK: - Dealer Hand View 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")") } } // 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)" } }