// // MiniBaccaratTableView.swift // Baccarat // // A realistic mini baccarat table layout for single player with side bets. // 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 private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.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) // Main table 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: Design.Spacing.xSmall) { // Top row: B PAIR | TIE | P PAIR TopBettingRow( bankerPairAmount: betAmount(for: .bankerPair), tieAmount: betAmount(for: .tie), playerPairAmount: betAmount(for: .playerPair), canBetBankerPair: canAddBet(for: .bankerPair), canBetTie: canAddBet(for: .tie), canBetPlayerPair: canAddBet(for: .playerPair), isBankerPairAtMax: isAtMax(for: .bankerPair), isTieAtMax: isAtMax(for: .tie), isPlayerPairAtMax: isAtMax(for: .playerPair), onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) }, onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) }, onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) } ) .padding(.horizontal, Design.Spacing.medium) .padding(.top, Design.Spacing.large) // Middle row: BANKER | DRAGON BONUS MainBetRow( title: "BANKER", payoutText: "PAYS 0.95 TO 1", mainBetAmount: betAmount(for: .banker), bonusBetAmount: betAmount(for: .dragonBonusBanker), isSelected: isBankerSelected, canBetMain: canAddBet(for: .banker), canBetBonus: canAddBet(for: .dragonBonusBanker), isMainAtMax: isAtMax(for: .banker), isBonusAtMax: isAtMax(for: .dragonBonusBanker), mainColor: Color.BettingZone.bankerLight, mainColorDark: Color.BettingZone.bankerDark, onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) }, onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) } ) .padding(.horizontal, Design.Spacing.medium) // Bottom row: PLAYER | DRAGON BONUS MainBetRow( title: "PLAYER", payoutText: "PAYS 1 TO 1", mainBetAmount: betAmount(for: .player), bonusBetAmount: betAmount(for: .dragonBonusPlayer), isSelected: isPlayerSelected, canBetMain: canAddBet(for: .player), canBetBonus: canAddBet(for: .dragonBonusPlayer), isMainAtMax: isAtMax(for: .player), isBonusAtMax: isAtMax(for: .dragonBonusPlayer), mainColor: Color.BettingZone.playerLight, mainColorDark: Color.BettingZone.playerDark, onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) }, onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) } ) .padding(.horizontal, Design.Spacing.medium) .padding(.bottom, Design.Spacing.large) } } .aspectRatio(Design.Size.tableAspectRatio, contentMode: .fit) } } } // MARK: - Top Betting Row (B PAIR | TIE | P PAIR) private struct TopBettingRow: View { let bankerPairAmount: Int let tieAmount: Int let playerPairAmount: Int let canBetBankerPair: Bool let canBetTie: Bool let canBetPlayerPair: Bool let isBankerPairAtMax: Bool let isTieAtMax: Bool let isPlayerPairAtMax: Bool let onBankerPair: () -> Void let onTie: () -> Void let onPlayerPair: () -> Void private let rowHeight: CGFloat = 48 private let cornerRadius = Design.CornerRadius.small var body: some View { HStack(spacing: Design.Spacing.xSmall) { // B PAIR PairBetZone( title: "B PAIR", betAmount: bankerPairAmount, isEnabled: canBetBankerPair, isAtMax: isBankerPairAtMax, color: Color.BettingZone.bankerLight, action: onBankerPair ) // TIE TieBetZone( betAmount: tieAmount, isEnabled: canBetTie, isAtMax: isTieAtMax, action: onTie ) // P PAIR PairBetZone( title: "P PAIR", betAmount: playerPairAmount, isEnabled: canBetPlayerPair, isAtMax: isPlayerPairAtMax, color: Color.BettingZone.playerLight, action: onPlayerPair ) } .frame(height: rowHeight) } } // MARK: - Pair Bet Zone private struct PairBetZone: View { let title: String let betAmount: Int let isEnabled: Bool let isAtMax: Bool let color: Color let action: () -> Void private let cornerRadius = Design.CornerRadius.small private let titleFontSize: CGFloat = 11 private let payoutFontSize: CGFloat = 9 var body: some View { Button { if isEnabled { action() } } label: { ZStack { // Background RoundedRectangle(cornerRadius: cornerRadius) .fill(color.opacity(0.6)) // Border RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.thin) // Content VStack(spacing: 1) { Text(title) .font(.system(size: titleFontSize, weight: .bold, design: .rounded)) .foregroundStyle(.yellow) Text("11:1") .font(.system(size: payoutFontSize, weight: .medium)) .foregroundStyle(.white.opacity(0.8)) } // Chip indicator if betAmount > 0 { SmallChipIndicator(amount: betAmount, isMax: isAtMax) .offset(x: 0, y: 12) } } } .buttonStyle(.plain) .opacity(isEnabled ? 1.0 : 0.6) .accessibilityElement(children: .ignore) .accessibilityLabel("\(title) bet, pays 11 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) } } // MARK: - Tie Bet Zone private struct TieBetZone: View { let betAmount: Int let isEnabled: Bool let isAtMax: Bool let action: () -> Void private let cornerRadius = Design.CornerRadius.small private let titleFontSize: CGFloat = 13 private let payoutFontSize: CGFloat = 9 var body: some View { Button { if isEnabled { action() } } label: { ZStack { // Background RoundedRectangle(cornerRadius: cornerRadius) .fill(isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie) // Border RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.thin) // Content VStack(spacing: 1) { Text("TIE") .font(.system(size: titleFontSize, weight: .black, design: .rounded)) .tracking(1) Text("8 TO 1") .font(.system(size: payoutFontSize, weight: .medium)) .opacity(0.8) } .foregroundStyle(.white) // Chip indicator if betAmount > 0 { SmallChipIndicator(amount: betAmount, isMax: isAtMax) .offset(x: 0, y: 12) } } } .buttonStyle(.plain) .opacity(isEnabled ? 1.0 : 0.6) .accessibilityElement(children: .ignore) .accessibilityLabel("Tie bet, pays 8 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) } } // MARK: - Main Bet Row (BANKER/PLAYER with BONUS) private struct MainBetRow: View { let title: String let payoutText: String let mainBetAmount: Int let bonusBetAmount: Int let isSelected: Bool let canBetMain: Bool let canBetBonus: Bool let isMainAtMax: Bool let isBonusAtMax: Bool let mainColor: Color let mainColorDark: Color let onMain: () -> Void let onBonus: () -> Void private let rowHeight: CGFloat = 55 private let bonusWidth: CGFloat = 70 private let cornerRadius = Design.CornerRadius.medium var body: some View { HStack(spacing: Design.Spacing.xSmall) { // Main bet zone (BANKER or PLAYER) MainBetZone( title: title, payoutText: payoutText, betAmount: mainBetAmount, isSelected: isSelected, isEnabled: canBetMain, isAtMax: isMainAtMax, colorLight: mainColor, colorDark: mainColorDark, action: onMain ) // Dragon Bonus zone DragonBonusZone( betAmount: bonusBetAmount, isEnabled: canBetBonus, isAtMax: isBonusAtMax, action: onBonus ) .frame(width: bonusWidth) } .frame(height: rowHeight) } } // MARK: - Main Bet Zone (BANKER or PLAYER) private struct MainBetZone: View { let title: String let payoutText: String let betAmount: Int let isSelected: Bool let isEnabled: Bool let isAtMax: Bool let colorLight: Color let colorDark: Color let action: () -> Void private let cornerRadius = Design.CornerRadius.medium private let titleFontSize: CGFloat = 18 private let payoutFontSize: CGFloat = 9 var body: some View { Button { if isEnabled { action() } } label: { ZStack { // Background gradient RoundedRectangle(cornerRadius: cornerRadius) .fill( LinearGradient( colors: isAtMax ? [colorLight.opacity(0.5), colorDark.opacity(0.5)] : [colorLight, colorDark], startPoint: .top, endPoint: .bottom ) ) // Selection glow if isSelected { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick) .shadow(color: .yellow.opacity(0.5), radius: 6) } // Border RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.medium) // Content VStack(spacing: 2) { Text(title) .font(.system(size: titleFontSize, weight: .black, design: .rounded)) .tracking(2) Text(payoutText) .font(.system(size: payoutFontSize, weight: .medium)) .opacity(0.8) } .foregroundStyle(.white) .lineLimit(1) .minimumScaleFactor(0.7) // Chip indicator if betAmount > 0 { HStack { Spacer() ChipOnTableView(amount: betAmount, showMax: isAtMax) .padding(.trailing, Design.Spacing.small) } } } } .buttonStyle(.plain) .opacity(isEnabled ? 1.0 : 0.6) .accessibilityElement(children: .ignore) .accessibilityLabel("\(title) bet, \(payoutText)" + (isSelected ? ", selected" : "") + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) } } // MARK: - Dragon Bonus Zone private struct DragonBonusZone: View { let betAmount: Int let isEnabled: Bool let isAtMax: Bool let action: () -> Void private let cornerRadius = Design.CornerRadius.medium private let titleFontSize: CGFloat = 10 private let diamondSize: CGFloat = 20 var body: some View { Button { if isEnabled { action() } } label: { ZStack { // Background RoundedRectangle(cornerRadius: cornerRadius) .fill(Color.purple.opacity(0.5)) // Border RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.thin) // Content VStack(spacing: 4) { // Diamond shape DiamondShape() .fill(Color.purple.opacity(0.8)) .frame(width: diamondSize, height: diamondSize) .overlay( DiamondShape() .strokeBorder(Color.white.opacity(0.5), lineWidth: 1) ) Text("BONUS") .font(.system(size: titleFontSize, weight: .bold, design: .rounded)) .foregroundStyle(.white) } // Chip indicator if betAmount > 0 { SmallChipIndicator(amount: betAmount, isMax: isAtMax) .offset(y: 18) } } } .buttonStyle(.plain) .opacity(isEnabled ? 1.0 : 0.6) .accessibilityElement(children: .ignore) .accessibilityLabel("Dragon Bonus, pays up to 30 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) } } // MARK: - Diamond Shape private struct DiamondShape: InsettableShape { var insetAmount: CGFloat = 0 func path(in rect: CGRect) -> Path { var path = Path() let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount) path.move(to: CGPoint(x: insetRect.midX, y: insetRect.minY)) path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.midY)) path.addLine(to: CGPoint(x: insetRect.midX, y: insetRect.maxY)) path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.midY)) path.closeSubpath() return path } func inset(by amount: CGFloat) -> some InsettableShape { var shape = self shape.insetAmount += amount return shape } } // MARK: - Small Chip Indicator private struct SmallChipIndicator: View { let amount: Int let isMax: Bool var body: some View { ZStack { Circle() .fill(isMax ? Color.gray : Color.yellow) .frame(width: 18, height: 18) Circle() .strokeBorder(Color.white, lineWidth: 1) .frame(width: 18, height: 18) if isMax { Text("M") .font(.system(size: 8, weight: .bold)) .foregroundStyle(.white) } else { Text(amount.formatted(.number.notation(.compactName))) .font(.system(size: 6, weight: .bold)) .foregroundStyle(.black) } } } } // MARK: - Table Felt Shape 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 } } #Preview { ZStack { Color.Table.baseDark .ignoresSafeArea() MiniBaccaratTableView( gameState: GameState(), selectedChip: .hundred ) .padding() } }