CasinoGames/Baccarat/Views/MiniBaccaratTableView.swift

543 lines
19 KiB
Swift

//
// MiniBaccaratTableView.swift
// Baccarat
//
// A modern baccarat table layout with all betting options.
//
import SwiftUI
import CasinoKit
/// The baccarat betting table layout with main bets and side bets.
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.small) {
// 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 betting table
VStack(spacing: 0) {
// 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) }
)
// Divider
Rectangle()
.fill(Color.Border.gold.opacity(Design.Opacity.medium))
.frame(height: Design.LineWidth.thin)
// Middle row: BANKER | BONUS
MainBetRow(
title: "BANKER",
payoutText: "0.95 : 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.bankerDark,
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
)
// Divider
Rectangle()
.fill(Color.Border.gold.opacity(Design.Opacity.medium))
.frame(height: Design.LineWidth.thin)
// Bottom row: PLAYER | BONUS
MainBetRow(
title: "PLAYER",
payoutText: "1 : 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.playerDark,
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
)
}
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(
LinearGradient(
colors: [Color.Border.goldLight, Color.Border.goldDark],
startPoint: .top,
endPoint: .bottom
),
lineWidth: Design.LineWidth.medium
)
)
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
}
}
}
// 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
var body: some View {
HStack(spacing: 0) {
// B PAIR
PairBetZone(
title: "B PAIR",
betAmount: bankerPairAmount,
isEnabled: canBetBankerPair,
isAtMax: isBankerPairAtMax,
color: Color.BettingZone.bankerDark.opacity(Design.Opacity.accent),
action: onBankerPair
)
// Vertical divider
Rectangle()
.fill(Color.Border.gold.opacity(Design.Opacity.medium))
.frame(width: Design.LineWidth.thin)
// TIE
TieBetZone(
betAmount: tieAmount,
isEnabled: canBetTie,
isAtMax: isTieAtMax,
action: onTie
)
// Vertical divider
Rectangle()
.fill(Color.Border.gold.opacity(Design.Opacity.medium))
.frame(width: Design.LineWidth.thin)
// P PAIR
PairBetZone(
title: "P PAIR",
betAmount: playerPairAmount,
isEnabled: canBetPlayerPair,
isAtMax: isPlayerPairAtMax,
color: Color.BettingZone.playerDark.opacity(Design.Opacity.accent),
action: onPlayerPair
)
}
.frame(height: Design.Size.topBetRowHeight)
}
}
// 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
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background
Rectangle()
.fill(color)
// Content
VStack(spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.body, weight: .heavy, design: .rounded))
.foregroundStyle(.yellow)
Text("11 : 1")
.font(.system(size: Design.BaseFontSize.small, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
// Chip indicator - center right with padding
if betAmount > 0 {
HStack {
Spacer()
ChipBadge(amount: betAmount, isMax: isAtMax)
.padding(.trailing, Design.Spacing.xSmall + Design.Spacing.xxSmall)
}
}
}
}
.buttonStyle(.plain)
.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
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background
Rectangle()
.fill(isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie)
// Content
VStack(spacing: Design.Spacing.xxSmall) {
Text("TIE")
.font(.system(size: Design.BaseFontSize.medium, weight: .black, design: .rounded))
.tracking(1)
Text("8 : 1")
.font(.system(size: Design.BaseFontSize.small, weight: .medium, design: .rounded))
.opacity(Design.Opacity.strong)
}
.foregroundStyle(.white)
// Chip indicator - center right with padding
if betAmount > 0 {
HStack {
Spacer()
ChipBadge(amount: betAmount, isMax: isAtMax)
.padding(.trailing, Design.Spacing.xSmall + Design.Spacing.xxSmall)
}
}
}
}
.buttonStyle(.plain)
.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 onMain: () -> Void
let onBonus: () -> Void
var body: some View {
HStack(spacing: 0) {
// Main bet zone (BANKER or PLAYER)
MainBetZone(
title: title,
payoutText: payoutText,
betAmount: mainBetAmount,
isSelected: isSelected,
isEnabled: canBetMain,
isAtMax: isMainAtMax,
color: mainColor,
action: onMain
)
// Vertical divider
Rectangle()
.fill(Color.Border.gold.opacity(Design.Opacity.medium))
.frame(width: Design.LineWidth.thin)
// Dragon Bonus zone
DragonBonusZone(
betAmount: bonusBetAmount,
isEnabled: canBetBonus,
isAtMax: isBonusAtMax,
action: onBonus
)
.frame(width: Design.Size.bonusZoneWidth)
}
.frame(height: Design.Size.mainBetRowHeight)
}
}
// 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 color: Color
let action: () -> Void
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background
Rectangle()
.fill(color)
// Selection highlight
if isSelected {
Rectangle()
.fill(Color.yellow.opacity(Design.Opacity.selection))
Rectangle()
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
}
// Content - always centered
VStack(spacing: Design.Spacing.xxSmall + 1) {
Text(title)
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .black, design: .rounded))
.tracking(2)
Text(payoutText)
.font(.system(size: Design.BaseFontSize.callout - 2, weight: .semibold, design: .rounded))
.opacity(Design.Opacity.strong)
}
.foregroundStyle(.white)
// Chip indicator - overlaid on right, doesn't affect centering
if betAmount > 0 {
HStack {
Spacer()
ChipOnTableView(amount: betAmount, showMax: isAtMax)
.padding(.trailing, Design.Spacing.small)
}
}
}
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title) bet, pays \(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
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background - darker purple/blue tint
Rectangle()
.fill(
LinearGradient(
colors: [
Color.BettingZone.dragonBonusLight,
Color.BettingZone.dragonBonusDark
],
startPoint: .top,
endPoint: .bottom
)
)
// Content
VStack(spacing: Design.Spacing.xSmall) {
// Diamond shape
DiamondShape()
.fill(
LinearGradient(
colors: [Color.purple.opacity(Design.Opacity.heavy), Color.purple.opacity(Design.Opacity.medium)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: Design.Size.diamondIcon, height: Design.Size.diamondIcon)
.overlay(
DiamondShape()
.strokeBorder(Color.white.opacity(Design.Opacity.overlay), lineWidth: Design.LineWidth.thin)
)
Text("BONUS")
.font(.system(size: Design.BaseFontSize.xSmall, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.almostFull))
}
// Chip indicator - center right with padding (same as top row)
if betAmount > 0 {
HStack {
Spacer()
ChipBadge(amount: betAmount, isMax: isAtMax)
.padding(.trailing, Design.Spacing.xSmall + Design.Spacing.xxSmall)
}
}
}
}
.buttonStyle(.plain)
.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: - Chip Badge (indicator for side bets)
private struct ChipBadge: View {
let amount: Int
let isMax: Bool
var body: some View {
ZStack {
// Outer ring
Circle()
.fill(isMax ? Color.gray : Color.yellow)
.frame(width: Design.Size.chipBadge, height: Design.Size.chipBadge)
// Inner decoration
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.almostFull), lineWidth: Design.LineWidth.standard)
.frame(width: Design.Size.chipBadgeInner, height: Design.Size.chipBadgeInner)
// Text
if isMax {
Text("MAX")
.font(.system(size: Design.BaseFontSize.xSmall, weight: .black))
.foregroundStyle(.white)
} else {
Text(formatCompact(amount))
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
.foregroundStyle(.black)
}
}
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, y: Design.Shadow.offsetSmall)
}
private func formatCompact(_ value: Int) -> String {
if value >= 1000 {
return "\(value / 1000)K"
}
return "\(value)"
}
}
#Preview {
ZStack {
Color.Table.baseDark
.ignoresSafeArea()
MiniBaccaratTableView(
gameState: GameState(),
selectedChip: .hundred
)
.padding()
}
}