634 lines
22 KiB
Swift
634 lines
22 KiB
Swift
//
|
|
// BettingTableView.swift
|
|
// Baccarat
|
|
//
|
|
// The baccarat betting table layout with main bets and side bets.
|
|
//
|
|
|
|
import SwiftUI
|
|
import CasinoKit
|
|
|
|
/// The baccarat betting table layout with main bets and side bets.
|
|
struct BettingTableView: View {
|
|
@Bindable var gameState: GameState
|
|
let selectedChip: ChipDenomination
|
|
|
|
// MARK: - Environment
|
|
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
|
|
/// Whether we're in landscape mode (compact vertical)
|
|
private var isLandscape: Bool {
|
|
verticalSizeClass == .compact
|
|
}
|
|
|
|
// MARK: - Adaptive Sizes
|
|
|
|
private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.small
|
|
|
|
/// Top bet row height - shorter in landscape
|
|
private var topRowHeight: CGFloat {
|
|
Design.Size.topBetRowHeight
|
|
}
|
|
|
|
/// Main bet row height - shorter in landscape
|
|
private var mainRowHeight: CGFloat {
|
|
Design.Size.mainBetRowHeight
|
|
}
|
|
|
|
// 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()
|
|
)
|
|
}
|
|
|
|
/// Banker payout text depends on game variant
|
|
private var bankerPayoutText: String {
|
|
gameState.settings.gameVariant == .commissionFree ? "1 : 1" : "0.95 : 1"
|
|
}
|
|
|
|
// Use global debug flag from Design constants
|
|
private var showDebugBorders: Bool { Design.showDebugBorders }
|
|
|
|
// 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)
|
|
.debugBorder(showDebugBorders, color: .gray, label: "Limits")
|
|
|
|
// 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),
|
|
rowHeight: topRowHeight,
|
|
onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) },
|
|
onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) },
|
|
onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) }
|
|
)
|
|
.debugBorder(showDebugBorders, color: .purple, label: "TopRow")
|
|
|
|
// Divider
|
|
Rectangle()
|
|
.fill(Color.Border.gold.opacity(Design.Opacity.medium))
|
|
.frame(height: Design.LineWidth.thin)
|
|
|
|
// Middle row: BANKER | BONUS
|
|
MainBetRow(
|
|
title: "BANKER",
|
|
payoutText: bankerPayoutText,
|
|
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,
|
|
rowHeight: mainRowHeight,
|
|
showBanker6Note: gameState.settings.gameVariant == .commissionFree,
|
|
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
|
|
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
|
|
)
|
|
.debugBorder(showDebugBorders, color: .red, label: "BankerRow")
|
|
|
|
// 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,
|
|
rowHeight: mainRowHeight,
|
|
showBanker6Note: false,
|
|
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
|
|
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
|
|
)
|
|
.debugBorder(showDebugBorders, color: .blue, label: "PlayerRow")
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
|
.strokeBorder(
|
|
LinearGradient(
|
|
colors: [Color.CasinoTable.goldLight, Color.CasinoTable.goldDark],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
),
|
|
lineWidth: Design.LineWidth.medium
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
|
|
.sherpaTag(BaccaratWalkthroughTags.bettingZone)
|
|
}
|
|
.debugBorder(showDebugBorders, color: .orange, label: "BettingTable")
|
|
}
|
|
}
|
|
|
|
// 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 rowHeight: CGFloat
|
|
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: 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
|
|
|
|
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 rowHeight: CGFloat
|
|
let showBanker6Note: Bool
|
|
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,
|
|
showBanker6Note: showBanker6Note,
|
|
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: 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 color: Color
|
|
let showBanker6Note: Bool
|
|
let action: () -> Void
|
|
|
|
/// Accessibility label with Banker 6 note if applicable
|
|
private var accessibilityLabelText: String {
|
|
var label = "\(title) bet, pays \(payoutText)"
|
|
if showBanker6Note {
|
|
label += ", Banker 6 pushes"
|
|
}
|
|
if isSelected {
|
|
label += ", selected"
|
|
}
|
|
if betAmount > 0 {
|
|
label += ", current bet $\(betAmount)"
|
|
}
|
|
return label
|
|
}
|
|
|
|
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.xLarge, weight: .black, design: .rounded))
|
|
.tracking(2)
|
|
.foregroundStyle(.white)
|
|
|
|
// Payout line - combined with 6 pushes note for Commission-Free
|
|
if showBanker6Note {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
Text(payoutText)
|
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
|
|
|
Text("•")
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
|
|
Text("6 PUSHES")
|
|
.foregroundStyle(.yellow.opacity(Design.Opacity.heavy))
|
|
}
|
|
.font(.system(size: Design.BaseFontSize.callout - 2, weight: .semibold, design: .rounded))
|
|
} else {
|
|
Text(payoutText)
|
|
.font(.system(size: Design.BaseFontSize.callout - 2, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
|
}
|
|
}
|
|
|
|
// 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(accessibilityLabelText)
|
|
.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: CasinoDesign.Size.chipBadge, height: CasinoDesign.Size.chipBadge)
|
|
|
|
// Inner decoration
|
|
Circle()
|
|
.strokeBorder(Color.white.opacity(Design.Opacity.almostFull), lineWidth: Design.LineWidth.standard)
|
|
.frame(width: CasinoDesign.Size.chipBadgeInner, height: CasinoDesign.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)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
#Preview("Betting Table") {
|
|
ZStack {
|
|
TableBackgroundView()
|
|
|
|
BettingTableView(
|
|
gameState: GameState(settings: GameSettings()),
|
|
selectedChip: .hundred
|
|
)
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
#Preview("With Bets") {
|
|
ZStack {
|
|
TableBackgroundView()
|
|
|
|
BettingTableView(
|
|
gameState: {
|
|
let state = GameState(settings: GameSettings())
|
|
state.placeBet(type: .player, amount: 100)
|
|
state.placeBet(type: .tie, amount: 25)
|
|
return state
|
|
}(),
|
|
selectedChip: .hundred
|
|
)
|
|
.padding()
|
|
}
|
|
}
|