// // GameTableView.swift // Baccarat // // The main baccarat table view with all game elements. // import SwiftUI import CasinoKit /// The main game table view containing all game elements. struct GameTableView: View { @State private var settings = GameSettings() @State private var gameState: GameState? @State private var selectedChip: ChipDenomination = .hundred @State private var showSettings = false @State private var showRules = false private var state: GameState { gameState ?? GameState(settings: settings) } private var playerIsWinner: Bool { state.lastResult == .playerWins } private var bankerIsWinner: Bool { state.lastResult == .bankerWins } private var isTie: Bool { state.lastResult == .tie } /// Builds descriptions for side bet wins to display in the result banner. private func buildSideBetDescriptions(state: GameState) -> [String] { var descriptions: [String] = [] // Check pair bets if state.playerHadPair && state.bet(for: .playerPair) != nil { descriptions.append("Player Pair Win!") } if state.bankerHadPair && state.bet(for: .bankerPair) != nil { descriptions.append("Banker Pair Win!") } // Check dragon bonus payouts if let payout = state.dragonBonusPayouts[.dragonBonusPlayer], payout > 0 { descriptions.append("Dragon Player +\(payout)") } if let payout = state.dragonBonusPayouts[.dragonBonusBanker], payout > 0 { descriptions.append("Dragon Banker +\(payout)") } return descriptions } var body: some View { ZStack { // Table background TableBackgroundView() // Main content VStack(spacing: 0) { // Top bar with balance and info TopBarView( balance: state.balance, cardsRemaining: state.engine.shoe.cardsRemaining, showCardsRemaining: settings.showCardsRemaining, onReset: { state.resetGame() }, onSettings: { showSettings = true }, onHelp: { showRules = true } ) Spacer(minLength: Design.Spacing.xSmall) // Cards display area CardsDisplayArea( playerCards: state.visiblePlayerCards, bankerCards: state.visibleBankerCards, playerCardsFaceUp: state.playerCardsFaceUp, bankerCardsFaceUp: state.bankerCardsFaceUp, playerValue: state.playerHandValue, bankerValue: state.bankerHandValue, playerIsWinner: playerIsWinner, bankerIsWinner: bankerIsWinner, isTie: isTie ) Spacer(minLength: Design.Spacing.xSmall) // Road map history if settings.showHistory && !state.roundHistory.isEmpty { RoadMapView(results: state.recentResults) .padding(.horizontal) } Spacer(minLength: Design.Spacing.small) // Mini Baccarat betting table MiniBaccaratTableView( gameState: state, selectedChip: selectedChip ) .padding(.horizontal, Design.Spacing.medium) Spacer(minLength: Design.Spacing.small) // Chip selector - shows higher chips as you win more! ChipSelectorView( selectedChip: $selectedChip, balance: state.balance, maxBet: state.maxBet ) .padding(.bottom, Design.Spacing.medium) // Action buttons ActionButtonsView( gameState: state, onDeal: { Task { await state.deal() } }, onClear: { state.clearBets() }, onNewRound: { state.newRound() } ) .padding(.horizontal) .padding(.bottom, Design.Spacing.xSmall) } .safeAreaPadding(.bottom) // Result banner overlay if state.showResultBanner, let result = state.lastResult { ResultBannerView( result: result, winnings: state.lastWinnings, playerHadPair: state.playerHadPair, bankerHadPair: state.bankerHadPair, sideBetWinnings: buildSideBetDescriptions(state: state) ) .transition(.opacity) // Confetti for wins if state.lastWinnings > 0 { ConfettiView() } } // Game Over overlay when broke if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating { GameOverView( roundsPlayed: state.roundHistory.count, onPlayAgain: { state.resetGame() } ) .transition(.opacity) } } .onAppear { if gameState == nil { gameState = GameState(settings: settings) } } .sheet(isPresented: $showSettings) { SettingsView(settings: settings) { // Apply settings when changed gameState?.applySettings() } } .fullScreenCover(isPresented: $showRules) { RulesHelpView() } } } /// Game over screen shown when player runs out of money. struct GameOverView: View { let roundsPlayed: Int let onPlayAgain: () -> Void @State private var showContent = false // MARK: - Scaled Font Sizes (Dynamic Type) @ScaledMetric(relativeTo: .largeTitle) private var iconSize: CGFloat = Design.BaseFontSize.display @ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle @ScaledMetric(relativeTo: .body) private var messageFontSize: CGFloat = Design.BaseFontSize.xLarge @ScaledMetric(relativeTo: .body) private var statsFontSize: CGFloat = 17 @ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.xLarge // MARK: - Layout Constants private let modalCornerRadius = Design.CornerRadius.xxxLarge private let statsCornerRadius = Design.CornerRadius.large private let cardPadding = Design.Spacing.xxxLarge private let contentSpacing: CGFloat = 28 private let buttonHorizontalPadding: CGFloat = 48 private let buttonVerticalPadding: CGFloat = 18 // MARK: - Body var body: some View { ZStack { // Solid dark backdrop - fully opaque Color.black .ignoresSafeArea() // Modal card VStack(spacing: contentSpacing) { // Broke icon Image(systemName: "creditcard.trianglebadge.exclamationmark") .font(.system(size: iconSize)) .foregroundStyle(.red) .symbolEffect(.pulse, options: .repeating) // Title Text("GAME OVER") .font(.system(size: titleFontSize, weight: .black, design: .rounded)) .foregroundStyle(.white) // Message Text("You've run out of chips!") .font(.system(size: messageFontSize, weight: .medium)) .foregroundStyle(.white.opacity(Design.Opacity.strong)) // Stats card VStack(spacing: Design.Spacing.medium) { HStack { Text("Rounds Played") .foregroundStyle(.white.opacity(Design.Opacity.medium)) Spacer() Text("\(roundsPlayed)") .bold() .foregroundStyle(.white) } } .font(.system(size: statsFontSize)) .padding() .background( RoundedRectangle(cornerRadius: statsCornerRadius) .fill(Color.white.opacity(0.08)) .overlay( RoundedRectangle(cornerRadius: statsCornerRadius) .strokeBorder(Color.white.opacity(Design.Opacity.subtle), lineWidth: Design.LineWidth.thin) ) ) .padding(.horizontal, Design.Spacing.xLarge) // Play Again button Button { onPlayAgain() } label: { HStack(spacing: Design.Spacing.small) { Image(systemName: "arrow.counterclockwise") Text("Play Again") } .font(.system(size: buttonFontSize, weight: .bold)) .foregroundStyle(.black) .padding(.horizontal, buttonHorizontalPadding) .padding(.vertical, buttonVerticalPadding) .background( Capsule() .fill( LinearGradient( colors: [Color.Button.goldLight, Color.Button.goldDark], startPoint: .top, endPoint: .bottom ) ) ) .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXLarge) } .padding(.top, Design.Spacing.medium) } .padding(cardPadding) .background( RoundedRectangle(cornerRadius: modalCornerRadius) .fill( LinearGradient( colors: [Color.Modal.backgroundLight, Color.Modal.backgroundDark], startPoint: .top, endPoint: .bottom ) ) .overlay( RoundedRectangle(cornerRadius: modalCornerRadius) .strokeBorder( LinearGradient( colors: [ Color.red.opacity(Design.Opacity.medium), Color.red.opacity(0.2) ], startPoint: .topLeading, endPoint: .bottomTrailing ), lineWidth: Design.LineWidth.medium ) ) ) .shadow(color: .red.opacity(0.2), radius: Design.Shadow.radiusXXLarge) .padding(.horizontal, Design.Spacing.xxLarge) .scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink) .opacity(showContent ? 1.0 : 0) } .onAppear { withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) { showContent = true } } .accessibilityElement(children: .contain) .accessibilityLabel(String(localized: "Game Over")) .accessibilityAddTraits(.isModal) } } /// The cards display area showing both hands. struct CardsDisplayArea: View { let playerCards: [Card] let bankerCards: [Card] let playerCardsFaceUp: [Bool] let bankerCardsFaceUp: [Bool] let playerValue: Int let bankerValue: Int let playerIsWinner: Bool let bankerIsWinner: Bool let isTie: Bool // MARK: - Fixed font sizes for card area // Fixed because the card display has strict layout constraints private let labelFontSize: CGFloat = 14 // MARK: - Accessibility private var playerHandDescription: String { if playerCards.isEmpty { return String(localized: "No cards") } let visibleCards = zip(playerCards, playerCardsFaceUp) .filter { $1 } .map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" } if visibleCards.isEmpty { return String(localized: "Cards face down") } let format = String(localized: "handValueFormat") return visibleCards.joined(separator: ", ") + ". " + String(format: format, playerValue) } private var bankerHandDescription: String { if bankerCards.isEmpty { return String(localized: "No cards") } let visibleCards = zip(bankerCards, bankerCardsFaceUp) .filter { $1 } .map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" } if visibleCards.isEmpty { return String(localized: "Cards face down") } let format = String(localized: "handValueFormat") return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue) } var body: some View { HStack(spacing: Design.Spacing.xxxLarge) { // Player side VStack(spacing: Design.Spacing.small) { // Label with value HStack(spacing: Design.Spacing.small) { Text("PLAYER") .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) .foregroundStyle(.white) if !playerCards.isEmpty && playerCardsFaceUp.contains(true) { ValueBadge(value: playerValue, color: .blue) } } .frame(minHeight: 30) // Cards CompactHandView( cards: playerCards, cardsFaceUp: playerCardsFaceUp, isWinner: playerIsWinner ) } .accessibilityElement(children: .ignore) .accessibilityLabel(String(localized: "Player hand")) .accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : "")) // Banker side VStack(spacing: Design.Spacing.small) { // Label with value HStack(spacing: Design.Spacing.small) { Text("BANKER") .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) .foregroundStyle(.white) if !bankerCards.isEmpty && bankerCardsFaceUp.contains(true) { ValueBadge(value: bankerValue, color: .red) } } .frame(minHeight: 30) // Cards CompactHandView( cards: bankerCards, cardsFaceUp: bankerCardsFaceUp, isWinner: bankerIsWinner ) } .accessibilityElement(children: .ignore) .accessibilityLabel(String(localized: "Banker hand")) .accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : "")) } .padding(.top, Design.Spacing.large) .padding(.bottom, Design.Spacing.xLarge) .padding(.horizontal, Design.Spacing.xLarge) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) .fill(Color.black.opacity(0.25)) .accessibilityHidden(true) ) .padding(.horizontal) } } /// A compact hand view showing cards in a row. struct CompactHandView: View { let cards: [Card] let cardsFaceUp: [Bool] let isWinner: Bool // MARK: - Scaled Font Sizes (Dynamic Type) @ScaledMetric(relativeTo: .caption) private var winBadgeFontSize: CGFloat = 10 // MARK: - Layout Constants // Fixed size: cards have strict visual constraints private let cardWidth: CGFloat = 45 private let cardOverlap: CGFloat = -12 private let placeholderSpacing: CGFloat = 8 var body: some View { HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) { if cards.isEmpty { // Placeholders - no overlap, just side by side ForEach(0..<2, id: \.self) { _ in CardPlaceholderView(width: cardWidth) } } else { ForEach(cards.indices, id: \.self) { index in let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false CardView( card: cards[index], isFaceUp: isFaceUp, cardWidth: cardWidth ) .zIndex(Double(index)) } } } .padding(Design.Spacing.xSmall) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.small) .strokeBorder( isWinner ? Color.yellow : Color.clear, lineWidth: Design.LineWidth.standard ) ) .overlay(alignment: .bottom) { if isWinner { Text("WIN") .font(.system(size: winBadgeFontSize, weight: .black)) .foregroundStyle(.black) .padding(.horizontal, Design.Spacing.small) .padding(.vertical, Design.Spacing.xxSmall) .background( Capsule() .fill(Color.yellow) ) .offset(y: Design.Spacing.small) } } } } /// A small badge showing the hand value. struct ValueBadge: View { let value: Int let color: Color // MARK: - Scaled Font Sizes (Dynamic Type) @ScaledMetric(relativeTo: .headline) private var valueFontSize: CGFloat = 15 @ScaledMetric(relativeTo: .headline) private var badgeSize: CGFloat = 26 var body: some View { Text("\(value)") .font(.system(size: valueFontSize, weight: .black, design: .rounded)) .foregroundStyle(.white) .frame(width: badgeSize, height: badgeSize) .background( Circle() .fill(color) ) } } /// The casino table background. struct TableBackgroundView: View { var body: some View { ZStack { // Base dark green Color.Table.baseDark // Radial gradient for depth RadialGradient( colors: [ Color.Table.backgroundLight, Color.Table.backgroundDark ], center: .center, startRadius: 50, endRadius: 500 ) // Subtle felt texture FeltPatternView() .opacity(Design.Opacity.subtle / 3) } .ignoresSafeArea() .accessibilityHidden(true) // Decorative element } } /// Subtle felt texture pattern. struct FeltPatternView: View { var body: some View { Canvas { context, size in for _ in 0..<2000 { let x = Double.random(in: 0...size.width) let y = Double.random(in: 0...size.height) let dotSize = Double.random(in: 1...2) let rect = CGRect(x: x, y: y, width: dotSize, height: dotSize) context.fill(Path(ellipseIn: rect), with: .color(.white)) } } } } /// Top bar showing balance and game info. struct TopBarView: View { let balance: Int let cardsRemaining: Int let showCardsRemaining: Bool let onReset: () -> Void let onSettings: () -> Void let onHelp: () -> Void // MARK: - Environment @Environment(\.dynamicTypeSize) private var dynamicTypeSize /// Whether the current text size is an accessibility size (very large) private var isAccessibilitySize: Bool { dynamicTypeSize.isAccessibilitySize } // MARK: - Fixed font sizes for constrained top bar // These use fixed sizes because the top bar has strict space constraints // and must remain readable at all accessibility settings private let labelFontSize: CGFloat = 9 private let currencyFontSize: CGFloat = 14 private let balanceFontSize: CGFloat = 20 private let smallFontSize: CGFloat = 12 private let buttonFontSize: CGFloat = 16 var body: some View { HStack { // Balance display - simplified at accessibility sizes HStack(spacing: Design.Spacing.xSmall) { Text("$") .font(.system(size: currencyFontSize, weight: .bold)) .foregroundStyle(.yellow.opacity(0.8)) Text(balance, format: .number) .font(.system(size: balanceFontSize, weight: .black, design: .rounded)) .foregroundStyle(.white) .contentTransition(.numericText()) .animation(.spring(duration: Design.Animation.quick), value: balance) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) } .padding(.horizontal, Design.Spacing.medium) .padding(.vertical, Design.Spacing.xSmall) .background( Capsule() .fill(Color.black.opacity(Design.Opacity.overlay)) ) .accessibilityElement(children: .ignore) .accessibilityLabel(String(localized: "Balance")) .accessibilityValue("$\(balance.formatted())") Spacer() // Cards remaining indicator - hidden at accessibility sizes to save space if showCardsRemaining && !isAccessibilitySize { HStack(spacing: Design.Spacing.xSmall) { Image(systemName: "rectangle.portrait.on.rectangle.portrait.fill") .font(.system(size: smallFontSize)) Text("\(cardsRemaining)") .font(.system(size: smallFontSize, weight: .medium)) } .foregroundStyle(.white.opacity(Design.Opacity.secondary)) .accessibilityElement(children: .ignore) .accessibilityLabel(String(localized: "Cards remaining in shoe")) .accessibilityValue("\(cardsRemaining)") Spacer() } // Help/Rules button Button("Help", systemImage: "info.circle.fill", action: onHelp) .labelStyle(.iconOnly) .font(.system(size: buttonFontSize)) .foregroundStyle(.white.opacity(0.6)) .padding(Design.Spacing.small) .background( Circle() .fill(Color.black.opacity(Design.Opacity.overlay)) ) // Settings button (icon only) Button("Settings", systemImage: "gearshape.fill", action: onSettings) .labelStyle(.iconOnly) .font(.system(size: buttonFontSize)) .foregroundStyle(.white.opacity(0.6)) .padding(Design.Spacing.small) .background( Circle() .fill(Color.black.opacity(Design.Opacity.overlay)) ) // Reset button (icon only to save space) Button("Reset", systemImage: "arrow.counterclockwise", action: onReset) .labelStyle(.iconOnly) .font(.system(size: buttonFontSize)) .foregroundStyle(.white.opacity(0.6)) .padding(Design.Spacing.small) .background( Circle() .fill(Color.black.opacity(Design.Opacity.overlay)) ) } .padding(.horizontal) .padding(.top, Design.Spacing.xSmall) } } /// Action buttons for deal, clear, and new round. struct ActionButtonsView: View { @Bindable var gameState: GameState let onDeal: () -> Void let onClear: () -> Void let onNewRound: () -> Void // MARK: - Environment @Environment(\.dynamicTypeSize) private var dynamicTypeSize /// Whether the current text size is an accessibility size (very large) private var isAccessibilitySize: Bool { dynamicTypeSize.isAccessibilitySize } // MARK: - Fixed font sizes for action buttons // Fixed because buttons have constrained space and must remain usable private let buttonFontSize: CGFloat = 16 private let iconSize: CGFloat = 24 private let statusFontSize: CGFloat = 14 var body: some View { HStack(spacing: Design.Spacing.medium) { if gameState.currentPhase == .betting { // Clear bets button - icon only at accessibility sizes clearButton // Deal button - icon only at accessibility sizes dealButton } else if gameState.currentPhase == .roundComplete { // New round button - icon only at accessibility sizes newRoundButton } else { // Playing indicator HStack(spacing: Design.Spacing.xSmall) { ProgressView() .tint(.white) .scaleEffect(0.8) Text("Dealing...") .font(.system(size: statusFontSize, weight: .medium)) .foregroundStyle(.white.opacity(Design.Opacity.heavy)) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.relaxed) } .padding(.horizontal, Design.Spacing.xLarge) .padding(.vertical, Design.Spacing.medium) } } } @ViewBuilder private var clearButton: some View { if isAccessibilitySize { Button("Clear", systemImage: "xmark.circle", action: onClear) .labelStyle(.iconOnly) .font(.system(size: iconSize, weight: .semibold)) .foregroundStyle(.white) .padding(Design.Spacing.medium) .background( Circle() .fill(Color.Button.destructive) ) .opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0) .disabled(gameState.currentBets.isEmpty) } else { Button("Clear", systemImage: "xmark.circle", action: onClear) .labelStyle(.titleOnly) .font(.system(size: buttonFontSize, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, Design.Spacing.xLarge) .padding(.vertical, Design.Spacing.medium) .background( Capsule() .fill(Color.Button.destructive) ) .opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0) .disabled(gameState.currentBets.isEmpty) } } @ViewBuilder private var dealButton: some View { if isAccessibilitySize { Button("Deal", systemImage: "play.fill", action: onDeal) .labelStyle(.iconOnly) .font(.system(size: iconSize, weight: .bold)) .foregroundStyle(.black) .padding(Design.Spacing.medium) .background( Circle() .fill( LinearGradient( colors: [Color.Button.goldLight, Color.Button.goldDark], startPoint: .top, endPoint: .bottom ) ) ) .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) .opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled) .disabled(!gameState.canDeal) } else { Button("Deal", systemImage: "play.fill", action: onDeal) .labelStyle(.titleOnly) .font(.system(size: buttonFontSize, weight: .bold)) .foregroundStyle(.black) .padding(.horizontal, Design.Spacing.xxxLarge) .padding(.vertical, Design.Spacing.medium) .background( Capsule() .fill( LinearGradient( colors: [Color.Button.goldLight, Color.Button.goldDark], startPoint: .top, endPoint: .bottom ) ) ) .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) .opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled) .disabled(!gameState.canDeal) } } @ViewBuilder private var newRoundButton: some View { if isAccessibilitySize { Button("New Round", systemImage: "arrow.right.circle", action: onNewRound) .labelStyle(.iconOnly) .font(.system(size: iconSize, weight: .bold)) .foregroundStyle(.black) .padding(Design.Spacing.medium) .background( Circle() .fill( LinearGradient( colors: [Color.Button.goldLight, Color.Button.goldDark], startPoint: .top, endPoint: .bottom ) ) ) .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) } else { Button("New Round", systemImage: "arrow.right.circle", action: onNewRound) .labelStyle(.titleOnly) .font(.system(size: buttonFontSize, weight: .bold)) .foregroundStyle(.black) .padding(.horizontal, Design.Spacing.xxxLarge) .padding(.vertical, Design.Spacing.medium) .background( Capsule() .fill( LinearGradient( colors: [Color.Button.goldLight, Color.Button.goldDark], startPoint: .top, endPoint: .bottom ) ) ) .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) } } } #Preview { GameTableView() }