Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-16 21:26:34 -06:00
parent e1d1f29793
commit baf9d88601

View File

@ -2,13 +2,13 @@
// MiniBaccaratTableView.swift
// Baccarat
//
// A realistic mini baccarat table layout for single player with side bets.
// A modern baccarat table layout with all betting options.
//
import SwiftUI
import CasinoKit
/// The semi-circular mini baccarat betting table layout.
/// The baccarat betting table layout with main bets and side bets.
struct MiniBaccaratTableView: View {
@Bindable var gameState: GameState
let selectedChip: ChipDenomination
@ -52,7 +52,7 @@ struct MiniBaccaratTableView: View {
// MARK: - Body
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
VStack(spacing: Design.Spacing.small) {
// Table limits label
Text(tableLimitsText)
.font(.system(size: tableLimitsFontSize, weight: .bold, design: .rounded))
@ -61,88 +61,79 @@ struct MiniBaccaratTableView: View {
.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
)
)
// 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) }
)
// Gold border
TableFeltShape()
// Divider
Rectangle()
.fill(Color.Border.gold.opacity(0.5))
.frame(height: 1)
// 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(0.5))
.frame(height: 1)
// 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.heavy
lineWidth: Design.LineWidth.medium
)
// 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)
)
.shadow(color: .black.opacity(0.3), radius: 10, y: 5)
}
}
}
@ -163,21 +154,25 @@ private struct TopBettingRow: View {
let onTie: () -> Void
let onPlayerPair: () -> Void
private let rowHeight: CGFloat = 48
private let cornerRadius = Design.CornerRadius.small
private let rowHeight: CGFloat = 52
var body: some View {
HStack(spacing: Design.Spacing.xSmall) {
HStack(spacing: 0) {
// B PAIR
PairBetZone(
title: "B PAIR",
betAmount: bankerPairAmount,
isEnabled: canBetBankerPair,
isAtMax: isBankerPairAtMax,
color: Color.BettingZone.bankerLight,
color: Color.BettingZone.bankerDark.opacity(0.6),
action: onBankerPair
)
// Vertical divider
Rectangle()
.fill(Color.Border.gold.opacity(0.5))
.frame(width: 1)
// TIE
TieBetZone(
betAmount: tieAmount,
@ -186,13 +181,18 @@ private struct TopBettingRow: View {
action: onTie
)
// Vertical divider
Rectangle()
.fill(Color.Border.gold.opacity(0.5))
.frame(width: 1)
// P PAIR
PairBetZone(
title: "P PAIR",
betAmount: playerPairAmount,
isEnabled: canBetPlayerPair,
isAtMax: isPlayerPairAtMax,
color: Color.BettingZone.playerLight,
color: Color.BettingZone.playerDark.opacity(0.6),
action: onPlayerPair
)
}
@ -210,9 +210,8 @@ private struct PairBetZone: View {
let color: Color
let action: () -> Void
private let cornerRadius = Design.CornerRadius.small
private let titleFontSize: CGFloat = 11
private let payoutFontSize: CGFloat = 9
private let titleFontSize: CGFloat = 12
private let payoutFontSize: CGFloat = 10
var body: some View {
Button {
@ -220,33 +219,29 @@ private struct PairBetZone: View {
} 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)
Rectangle()
.fill(color)
// Content
VStack(spacing: 1) {
VStack(spacing: 2) {
Text(title)
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
.font(.system(size: titleFontSize, weight: .heavy, design: .rounded))
.foregroundStyle(.yellow)
Text("11:1")
.font(.system(size: payoutFontSize, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
Text("11 : 1")
.font(.system(size: payoutFontSize, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
// Chip indicator
if betAmount > 0 {
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
.offset(x: 0, y: 12)
ChipBadge(amount: betAmount, isMax: isAtMax)
.offset(y: 16)
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.opacity(isEnabled ? 1.0 : 0.5)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title) bet, pays 11 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
@ -261,9 +256,8 @@ private struct TieBetZone: View {
let isAtMax: Bool
let action: () -> Void
private let cornerRadius = Design.CornerRadius.small
private let titleFontSize: CGFloat = 13
private let payoutFontSize: CGFloat = 9
private let titleFontSize: CGFloat = 14
private let payoutFontSize: CGFloat = 10
var body: some View {
Button {
@ -271,34 +265,30 @@ private struct TieBetZone: View {
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
Rectangle()
.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) {
VStack(spacing: 2) {
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)
Text("8 : 1")
.font(.system(size: payoutFontSize, weight: .medium, design: .rounded))
.opacity(0.7)
}
.foregroundStyle(.white)
// Chip indicator
if betAmount > 0 {
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
.offset(x: 0, y: 12)
ChipBadge(amount: betAmount, isMax: isAtMax)
.offset(y: 16)
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.opacity(isEnabled ? 1.0 : 0.5)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Tie bet, pays 8 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
@ -318,16 +308,14 @@ private struct MainBetRow: View {
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
private let rowHeight: CGFloat = 65
private let bonusWidth: CGFloat = 80
var body: some View {
HStack(spacing: Design.Spacing.xSmall) {
HStack(spacing: 0) {
// Main bet zone (BANKER or PLAYER)
MainBetZone(
title: title,
@ -336,11 +324,15 @@ private struct MainBetRow: View {
isSelected: isSelected,
isEnabled: canBetMain,
isAtMax: isMainAtMax,
colorLight: mainColor,
colorDark: mainColorDark,
color: mainColor,
action: onMain
)
// Vertical divider
Rectangle()
.fill(Color.Border.gold.opacity(0.5))
.frame(width: 1)
// Dragon Bonus zone
DragonBonusZone(
betAmount: bonusBetAmount,
@ -363,68 +355,59 @@ private struct MainBetZone: View {
let isSelected: Bool
let isEnabled: Bool
let isAtMax: Bool
let colorLight: Color
let colorDark: Color
let color: Color
let action: () -> Void
private let cornerRadius = Design.CornerRadius.medium
private let titleFontSize: CGFloat = 18
private let payoutFontSize: CGFloat = 9
private let titleFontSize: CGFloat = 20
private let payoutFontSize: CGFloat = 11
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
)
)
// Background
Rectangle()
.fill(color)
// Selection glow
// Selection highlight
if isSelected {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
.shadow(color: .yellow.opacity(0.5), radius: 6)
Rectangle()
.fill(Color.yellow.opacity(0.15))
Rectangle()
.strokeBorder(Color.yellow, lineWidth: 3)
}
// 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)
HStack {
Spacer()
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()
VStack(spacing: 3) {
Text(title)
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(2)
Text(payoutText)
.font(.system(size: payoutFontSize, weight: .semibold, design: .rounded))
.opacity(0.7)
}
.foregroundStyle(.white)
Spacer()
// Chip indicator
if betAmount > 0 {
ChipOnTableView(amount: betAmount, showMax: isAtMax)
.padding(.trailing, Design.Spacing.small)
.padding(.trailing, Design.Spacing.medium)
}
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.opacity(isEnabled ? 1.0 : 0.5)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title) bet, \(payoutText)" + (isSelected ? ", selected" : "") + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityLabel("\(title) bet, pays \(payoutText)" + (isSelected ? ", selected" : "") + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
}
}
@ -437,48 +420,58 @@ private struct DragonBonusZone: View {
let isAtMax: Bool
let action: () -> Void
private let cornerRadius = Design.CornerRadius.medium
private let titleFontSize: CGFloat = 10
private let diamondSize: CGFloat = 20
private let titleFontSize: CGFloat = 9
private let diamondSize: CGFloat = 24
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)
// Background - darker purple/blue tint
Rectangle()
.fill(
LinearGradient(
colors: [
Color(red: 0.25, green: 0.3, blue: 0.45),
Color(red: 0.15, green: 0.2, blue: 0.35)
],
startPoint: .top,
endPoint: .bottom
)
)
// Content
VStack(spacing: 4) {
VStack(spacing: 5) {
// Diamond shape
DiamondShape()
.fill(Color.purple.opacity(0.8))
.fill(
LinearGradient(
colors: [Color.purple.opacity(0.8), Color.purple.opacity(0.5)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: diamondSize, height: diamondSize)
.overlay(
DiamondShape()
.strokeBorder(Color.white.opacity(0.5), lineWidth: 1)
.strokeBorder(Color.white.opacity(0.4), lineWidth: 1)
)
Text("BONUS")
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.foregroundStyle(.white.opacity(0.9))
}
// Chip indicator
if betAmount > 0 {
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
.offset(y: 18)
ChipBadge(amount: betAmount, isMax: isAtMax)
.offset(y: 22)
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.opacity(isEnabled ? 1.0 : 0.5)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Dragon Bonus, pays up to 30 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
@ -510,9 +503,9 @@ private struct DiamondShape: InsettableShape {
}
}
// MARK: - Small Chip Indicator
// MARK: - Chip Badge (small indicator)
private struct SmallChipIndicator: View {
private struct ChipBadge: View {
let amount: Int
let isMax: Bool
@ -520,77 +513,29 @@ private struct SmallChipIndicator: View {
ZStack {
Circle()
.fill(isMax ? Color.gray : Color.yellow)
.frame(width: 18, height: 18)
.frame(width: 20, height: 20)
Circle()
.strokeBorder(Color.white, lineWidth: 1)
.frame(width: 18, height: 18)
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1)
.frame(width: 20, height: 20)
if isMax {
Text("M")
.font(.system(size: 8, weight: .bold))
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
} else {
Text(amount.formatted(.number.notation(.compactName)))
.font(.system(size: 6, weight: .bold))
Text(formatCompact(amount))
.font(.system(size: 7, 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
private func formatCompact(_ value: Int) -> String {
if value >= 1000 {
return "\(value / 1000)K"
}
return "\(value)"
}
}