// // MiniBaccaratTableView.swift // Baccarat // // A realistic mini baccarat table layout for single player. // import SwiftUI import CasinoKit /// The semi-circular mini baccarat betting table layout. struct MiniBaccaratTableView: View { @Bindable var gameState: GameState let selectedChip: ChipDenomination // MARK: - Fixed Font Sizes // Fixed because the table area has strict layout constraints private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.small // MARK: - Layout Constants 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) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.comfortable) 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: - Fixed Font Sizes // Fixed because betting zones have strict space constraints private let titleFontSize: CGFloat = Design.BaseFontSize.medium private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall // MARK: - Accessibility private var accessibilityDescription: String { var description = String(localized: "Tie bet, pays 8 to 1") if betAmount > 0 { let format = String(localized: "currentBetFormat") description += ". " + String(format: format, betAmount.formatted()) if isAtMax { description += ", " + String(localized: "maximum bet") } } return description } private var accessibilityHintText: String { if isEnabled { return String(localized: "Double tap to place bet") } else if isAtMax { return String(localized: "Maximum bet reached") } else { return String(localized: "Betting disabled") } } // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.small 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) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) Text("PAYS 8 TO 1") .font(.system(size: subtitleFontSize, weight: .medium)) .opacity(Design.Opacity.heavy) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) } .foregroundStyle(.white) } // Chip overlaid on right side .overlay(alignment: .trailing) { if betAmount > 0 { ChipOnTableView(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) .accessibilityHidden(true) // Included in zone description } } } .buttonStyle(.plain) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) .accessibilityHint(accessibilityHintText) .accessibilityAddTraits(.isButton) } } /// 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: - Fixed Font Sizes // Fixed because betting zones have strict space constraints private let titleFontSize: CGFloat = Design.BaseFontSize.large private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall // MARK: - Accessibility private var accessibilityDescription: String { var description = String(localized: "Banker bet, pays 0.95 to 1") if isSelected { description += ", " + String(localized: "selected") } if betAmount > 0 { let format = String(localized: "currentBetFormat") description += ". " + String(format: format, betAmount.formatted()) if isAtMax { description += ", " + String(localized: "maximum bet") } } return description } private var accessibilityHintText: String { if isEnabled { return String(localized: "Double tap to place bet") } else if isAtMax { return String(localized: "Maximum bet reached") } else { return String(localized: "Betting disabled") } } // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.medium 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) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) Text("PAYS 0.95 TO 1") .font(.system(size: subtitleFontSize, weight: .medium)) .opacity(Design.Opacity.heavy) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) } .foregroundStyle(.white) } // Chip overlaid on right side .overlay(alignment: .trailing) { if betAmount > 0 { ChipOnTableView(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) .accessibilityHidden(true) // Included in zone description } } } .buttonStyle(.plain) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) .accessibilityHint(accessibilityHintText) .accessibilityAddTraits(.isButton) } } /// 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: - Fixed Font Sizes // Fixed because betting zones have strict space constraints private let titleFontSize: CGFloat = Design.BaseFontSize.large private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall // MARK: - Accessibility private var accessibilityDescription: String { var description = String(localized: "Player bet, pays 1 to 1") if isSelected { description += ", " + String(localized: "selected") } if betAmount > 0 { let format = String(localized: "currentBetFormat") description += ". " + String(format: format, betAmount.formatted()) if isAtMax { description += ", " + String(localized: "maximum bet") } } return description } private var accessibilityHintText: String { if isEnabled { return String(localized: "Double tap to place bet") } else if isAtMax { return String(localized: "Maximum bet reached") } else { return String(localized: "Betting disabled") } } // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.medium 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) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) Text("PAYS 1 TO 1") .font(.system(size: subtitleFontSize, weight: .medium)) .opacity(Design.Opacity.heavy) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) } .foregroundStyle(.white) } // Chip overlaid on right side .overlay(alignment: .trailing) { if betAmount > 0 { ChipOnTableView(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) .accessibilityHidden(true) // Included in zone description } } } .buttonStyle(.plain) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) .accessibilityHint(accessibilityHintText) .accessibilityAddTraits(.isButton) } } #Preview { ZStack { Color.Table.baseDark .ignoresSafeArea() MiniBaccaratTableView( gameState: GameState(), selectedChip: .hundred ) .padding() } }