CasinoGames/Baccarat/Views/MiniBaccaratTableView.swift

609 lines
21 KiB
Swift

//
// 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()
}
}