// // MiniBaccaratTableView.swift // Baccarat // // A realistic mini baccarat table layout for single player. // import SwiftUI /// The semi-circular mini baccarat betting table layout. struct MiniBaccaratTableView: View { @Bindable var gameState: GameState let selectedChip: ChipDenomination // MARK: - Layout Constants private let tableLimitsFontSize = Design.FontSize.small private let tieZoneHeight: CGFloat = 55 private let mainZoneHeight: CGFloat = 60 private let tieHorizontalPadding: CGFloat = 50 private let bankerHorizontalPadding: CGFloat = 30 private let playerHorizontalPadding: CGFloat = 20 private let zoneTopPadding = Design.Spacing.medium private let zoneBottomPadding = Design.Spacing.medium private let minSpacerLength = Design.Spacing.small // MARK: - Computed Properties private func betAmount(for type: BetType) -> Int { gameState.betAmount(for: type) } private func canAddBet(for type: BetType) -> Bool { gameState.canPlaceBet && gameState.balance >= selectedChip.rawValue && gameState.canAddToBet(type: type, amount: selectedChip.rawValue) } private func isAtMax(for type: BetType) -> Bool { betAmount(for: type) >= gameState.maxBet } private var isPlayerSelected: Bool { gameState.mainBet?.type == .player } private var isBankerSelected: Bool { gameState.mainBet?.type == .banker } private var tableLimitsText: String { String.localized( "tableLimitsFormat", gameState.minBet.formatted(), gameState.maxBet.formatted() ) } // MARK: - Body var body: some View { VStack(spacing: Design.Spacing.xSmall) { // Table limits label Text(tableLimitsText) .font(.system(size: tableLimitsFontSize, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) .tracking(1) ZStack { // Table felt background with arc shape TableFeltShape() .fill( LinearGradient( colors: [Color.Table.feltLight, Color.Table.feltDark], startPoint: .top, endPoint: .bottom ) ) // Gold border TableFeltShape() .strokeBorder( LinearGradient( colors: [Color.Border.goldLight, Color.Border.goldDark], startPoint: .top, endPoint: .bottom ), lineWidth: Design.LineWidth.heavy ) // Betting zones layout VStack(spacing: 0) { // TIE zone at top TieBettingZone( betAmount: betAmount(for: .tie), isEnabled: canAddBet(for: .tie), isAtMax: isAtMax(for: .tie) ) { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) } .frame(height: tieZoneHeight) .padding(.horizontal, tieHorizontalPadding) .padding(.top, zoneTopPadding) Spacer(minLength: minSpacerLength) // BANKER zone in middle BankerBettingZone( betAmount: betAmount(for: .banker), isSelected: isBankerSelected, isEnabled: canAddBet(for: .banker), isAtMax: isAtMax(for: .banker) ) { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) } .frame(height: mainZoneHeight) .padding(.horizontal, bankerHorizontalPadding) Spacer(minLength: minSpacerLength) // PLAYER zone at bottom PlayerBettingZone( betAmount: betAmount(for: .player), isSelected: isPlayerSelected, isEnabled: canAddBet(for: .player), isAtMax: isAtMax(for: .player) ) { gameState.placeBet(type: .player, amount: selectedChip.rawValue) } .frame(height: mainZoneHeight) .padding(.horizontal, playerHorizontalPadding) .padding(.bottom, zoneBottomPadding) } } .aspectRatio(Design.Size.tableAspectRatio, contentMode: .fit) } } } /// Custom shape for the mini baccarat table felt. struct TableFeltShape: InsettableShape { var insetAmount: CGFloat = 0 private let shapeCornerRadius = Design.CornerRadius.xxLarge func path(in rect: CGRect) -> Path { var path = Path() let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount) let height = insetRect.height let cornerRadius = shapeCornerRadius // Start from bottom left path.move(to: CGPoint(x: insetRect.minX + cornerRadius, y: insetRect.maxY)) // Bottom edge path.addLine(to: CGPoint(x: insetRect.maxX - cornerRadius, y: insetRect.maxY)) // Bottom right corner path.addQuadCurve( to: CGPoint(x: insetRect.maxX, y: insetRect.maxY - cornerRadius), control: CGPoint(x: insetRect.maxX, y: insetRect.maxY) ) // Right edge going up with curve path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.minY + height * 0.3)) // Top arc path.addQuadCurve( to: CGPoint(x: insetRect.minX, y: insetRect.minY + height * 0.3), control: CGPoint(x: insetRect.midX, y: insetRect.minY - height * 0.1) ) // Left edge going down path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.maxY - cornerRadius)) // Bottom left corner path.addQuadCurve( to: CGPoint(x: insetRect.minX + cornerRadius, y: insetRect.maxY), control: CGPoint(x: insetRect.minX, y: insetRect.maxY) ) path.closeSubpath() return path } func inset(by amount: CGFloat) -> some InsettableShape { var shape = self shape.insetAmount += amount return shape } } /// The TIE betting zone at the top of the table. struct TieBettingZone: View { let betAmount: Int let isEnabled: Bool var isAtMax: Bool = false let action: () -> Void // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.small private let titleFontSize = Design.FontSize.medium private let subtitleFontSize = Design.FontSize.xSmall private let chipTrailingPadding = Design.Spacing.small // MARK: - Computed Properties private var backgroundColor: Color { isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie } private var borderColor: Color { isAtMax ? Color.Border.silver : Color.Border.gold } // MARK: - Body var body: some View { Button { if isEnabled { action() } } label: { ZStack { // Background RoundedRectangle(cornerRadius: cornerRadius) .fill(backgroundColor) // Border RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(borderColor, lineWidth: Design.LineWidth.medium) // Centered text content VStack(spacing: Design.Spacing.xxSmall) { Text("TIE") .font(.system(size: titleFontSize, weight: .black, design: .rounded)) .tracking(2) Text("PAYS 8 TO 1") .font(.system(size: subtitleFontSize, weight: .medium)) .opacity(Design.Opacity.heavy) } .foregroundStyle(.white) } // Chip overlaid on right side .overlay(alignment: .trailing) { if betAmount > 0 { ChipOnTable(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) } } } .buttonStyle(.plain) } } /// The BANKER betting zone in the middle of the table. struct BankerBettingZone: View { let betAmount: Int let isSelected: Bool let isEnabled: Bool var isAtMax: Bool = false let action: () -> Void // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.medium private let titleFontSize = Design.FontSize.large private let subtitleFontSize = Design.FontSize.xSmall private let chipTrailingPadding = Design.Spacing.medium private let selectionShadowRadius = Design.Shadow.radiusSmall // MARK: - Computed Properties private var backgroundColors: [Color] { isAtMax ? [Color.BettingZone.bankerMaxLight, Color.BettingZone.bankerMaxDark] : [Color.BettingZone.bankerLight, Color.BettingZone.bankerDark] } private var borderColor: Color { isAtMax ? Color.Border.silver : Color.Border.gold } // MARK: - Body var body: some View { Button { if isEnabled { action() } } label: { ZStack { // Background RoundedRectangle(cornerRadius: cornerRadius) .fill( LinearGradient( colors: backgroundColors, startPoint: .top, endPoint: .bottom ) ) // Selection glow if isSelected { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick) .shadow(color: .yellow.opacity(Design.Opacity.medium), radius: selectionShadowRadius) } // Border RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(borderColor, lineWidth: Design.LineWidth.medium) // Centered text content VStack(spacing: Design.Spacing.xxSmall) { Text("BANKER") .font(.system(size: titleFontSize, weight: .black, design: .rounded)) .tracking(3) Text("PAYS 0.95 TO 1") .font(.system(size: subtitleFontSize, weight: .medium)) .opacity(Design.Opacity.heavy) } .foregroundStyle(.white) } // Chip overlaid on right side .overlay(alignment: .trailing) { if betAmount > 0 { ChipOnTable(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) } } } .buttonStyle(.plain) } } /// The PLAYER betting zone at the bottom of the table. struct PlayerBettingZone: View { let betAmount: Int let isSelected: Bool let isEnabled: Bool var isAtMax: Bool = false let action: () -> Void // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.medium private let titleFontSize = Design.FontSize.large private let subtitleFontSize = Design.FontSize.xSmall private let chipTrailingPadding = Design.Spacing.medium private let selectionShadowRadius = Design.Shadow.radiusSmall // MARK: - Computed Properties private var backgroundColors: [Color] { isAtMax ? [Color.BettingZone.playerMaxLight, Color.BettingZone.playerMaxDark] : [Color.BettingZone.playerLight, Color.BettingZone.playerDark] } private var borderColor: Color { isAtMax ? Color.Border.silver : Color.Border.gold } // MARK: - Body var body: some View { Button { if isEnabled { action() } } label: { ZStack { // Background RoundedRectangle(cornerRadius: cornerRadius) .fill( LinearGradient( colors: backgroundColors, startPoint: .top, endPoint: .bottom ) ) // Selection glow if isSelected { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick) .shadow(color: .yellow.opacity(Design.Opacity.medium), radius: selectionShadowRadius) } // Border RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(borderColor, lineWidth: Design.LineWidth.medium) // Centered text content VStack(spacing: Design.Spacing.xxSmall) { Text("PLAYER") .font(.system(size: titleFontSize, weight: .black, design: .rounded)) .tracking(3) Text("PAYS 1 TO 1") .font(.system(size: subtitleFontSize, weight: .medium)) .opacity(Design.Opacity.heavy) } .foregroundStyle(.white) } // Chip overlaid on right side .overlay(alignment: .trailing) { if betAmount > 0 { ChipOnTable(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) } } } .buttonStyle(.plain) } } /// A chip displayed on the table showing bet amount. struct ChipOnTable: View { let amount: Int var showMax: Bool = false // MARK: - Layout Constants private let chipSize = Design.Size.chipSmall private let innerRingSize: CGFloat = 26 private let gradientEndRadius: CGFloat = 20 private let maxBadgeFontSize = Design.FontSize.xxSmall private let maxBadgeOffsetX: CGFloat = 6 private let maxBadgeOffsetY: CGFloat = -4 // MARK: - Computed Properties private var chipColor: Color { switch amount { case 0..<50: return .blue case 50..<100: return .orange case 100..<500: return .black case 500..<1000: return .purple default: return Color.Chip.gold } } private var displayText: String { amount >= 1000 ? "\(amount / 1000)K" : "\(amount)" } private var textFontSize: CGFloat { amount >= 1000 ? Design.FontSize.small : 11 } // MARK: - Body var body: some View { ZStack { Circle() .fill( RadialGradient( colors: [chipColor.opacity(0.9), chipColor], center: .topLeading, startRadius: 0, endRadius: gradientEndRadius ) ) .frame(width: chipSize, height: chipSize) Circle() .strokeBorder(Color.white.opacity(Design.Opacity.heavy), lineWidth: Design.LineWidth.medium) .frame(width: chipSize, height: chipSize) Circle() .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) .frame(width: innerRingSize, height: innerRingSize) Text(displayText) .font(.system(size: textFontSize, weight: .bold)) .foregroundStyle(.white) } .shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, x: 1, y: 2) .overlay(alignment: .topTrailing) { if showMax { Text("MAX") .font(.system(size: maxBadgeFontSize, weight: .black)) .foregroundStyle(.white) .padding(.horizontal, Design.Spacing.xSmall) .padding(.vertical, Design.Spacing.xxSmall) .background( Capsule() .fill(Color.red) ) .offset(x: maxBadgeOffsetX, y: maxBadgeOffsetY) } } } } #Preview { ZStack { Color.Table.baseDark .ignoresSafeArea() MiniBaccaratTableView( gameState: GameState(), selectedChip: .hundred ) .padding() } }