// // 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 // 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, 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, 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)) } // 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 } return state.engine.getHint(playerHand: hand, dealerUpCard: upCard) } } // MARK: - Dealer Hand View struct DealerHandView: View { let hand: BlackjackHand let showHoleCard: 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) if !hand.cards.isEmpty && showHoleCard { ValueBadge(value: hand.value, 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 ) .zIndex(Double(index)) } } } // 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 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.85 default: // 4+ hands return cardWidth * 0.75 } } /// 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, // 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 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 ) .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)) Text(String(localized: "Min: $\(minBet)")) .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: - 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: - Card Accessibility Extension extension Card { var accessibilityDescription: String { "\(rank.accessibilityName) of \(suit.accessibilityName)" } }