CasinoGames/Baccarat/Views/MiniBaccaratTableView.swift

465 lines
16 KiB
Swift

//
// MiniBaccaratTableView.swift
// Baccarat
//
// A realistic mini baccarat table layout for single player.
//
import SwiftUI
/// The semi-circular mini baccarat betting table layout.
struct MiniBaccaratTableView: View {
@Bindable var gameState: GameState
let selectedChip: ChipDenomination
// MARK: - Fixed Font Sizes
// Fixed because the table area has strict layout constraints
private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.small
// MARK: - Layout Constants
private let tieZoneHeight: CGFloat = 55
private let mainZoneHeight: CGFloat = 60
private let tieHorizontalPadding: CGFloat = 50
private let bankerHorizontalPadding: CGFloat = 30
private let playerHorizontalPadding: CGFloat = 20
private let zoneTopPadding = Design.Spacing.medium
private let zoneBottomPadding = Design.Spacing.medium
private let minSpacerLength = Design.Spacing.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(0.6)
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: 0) {
// TIE zone at top
TieBettingZone(
betAmount: betAmount(for: .tie),
isEnabled: canAddBet(for: .tie),
isAtMax: isAtMax(for: .tie)
) {
gameState.placeBet(type: .tie, amount: selectedChip.rawValue)
}
.frame(height: tieZoneHeight)
.padding(.horizontal, tieHorizontalPadding)
.padding(.top, zoneTopPadding)
Spacer(minLength: minSpacerLength)
// BANKER zone in middle
BankerBettingZone(
betAmount: betAmount(for: .banker),
isSelected: isBankerSelected,
isEnabled: canAddBet(for: .banker),
isAtMax: isAtMax(for: .banker)
) {
gameState.placeBet(type: .banker, amount: selectedChip.rawValue)
}
.frame(height: mainZoneHeight)
.padding(.horizontal, bankerHorizontalPadding)
Spacer(minLength: minSpacerLength)
// PLAYER zone at bottom
PlayerBettingZone(
betAmount: betAmount(for: .player),
isSelected: isPlayerSelected,
isEnabled: canAddBet(for: .player),
isAtMax: isAtMax(for: .player)
) {
gameState.placeBet(type: .player, amount: selectedChip.rawValue)
}
.frame(height: mainZoneHeight)
.padding(.horizontal, playerHorizontalPadding)
.padding(.bottom, zoneBottomPadding)
}
}
.aspectRatio(Design.Size.tableAspectRatio, contentMode: .fit)
}
}
}
/// Custom shape for the mini baccarat table felt.
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
}
}
/// The TIE betting zone at the top of the table.
struct TieBettingZone: View {
let betAmount: Int
let isEnabled: Bool
var isAtMax: Bool = false
let action: () -> Void
// MARK: - Fixed Font Sizes
// Fixed because betting zones have strict space constraints
private let titleFontSize: CGFloat = Design.BaseFontSize.medium
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.small
private let chipTrailingPadding = Design.Spacing.small
// MARK: - Computed Properties
private var backgroundColor: Color {
isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie
}
private var borderColor: Color {
isAtMax ? Color.Border.silver : Color.Border.gold
}
// MARK: - Body
var body: some View {
Button {
if isEnabled {
action()
}
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(backgroundColor)
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
// Centered text content
VStack(spacing: Design.Spacing.xxSmall) {
Text("TIE")
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(2)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text("PAYS 8 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
.foregroundStyle(.white)
}
// Chip overlaid on right side
.overlay(alignment: .trailing) {
if betAmount > 0 {
ChipOnTable(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding)
}
}
}
.buttonStyle(.plain)
}
}
/// The BANKER betting zone in the middle of the table.
struct BankerBettingZone: View {
let betAmount: Int
let isSelected: Bool
let isEnabled: Bool
var isAtMax: Bool = false
let action: () -> Void
// MARK: - Fixed Font Sizes
// Fixed because betting zones have strict space constraints
private let titleFontSize: CGFloat = Design.BaseFontSize.large
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.medium
private let chipTrailingPadding = Design.Spacing.medium
private let selectionShadowRadius = Design.Shadow.radiusSmall
// MARK: - Computed Properties
private var backgroundColors: [Color] {
isAtMax
? [Color.BettingZone.bankerMaxLight, Color.BettingZone.bankerMaxDark]
: [Color.BettingZone.bankerLight, Color.BettingZone.bankerDark]
}
private var borderColor: Color {
isAtMax ? Color.Border.silver : Color.Border.gold
}
// MARK: - Body
var body: some View {
Button {
if isEnabled {
action()
}
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(
LinearGradient(
colors: backgroundColors,
startPoint: .top,
endPoint: .bottom
)
)
// Selection glow
if isSelected {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
.shadow(color: .yellow.opacity(Design.Opacity.medium), radius: selectionShadowRadius)
}
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
// Centered text content
VStack(spacing: Design.Spacing.xxSmall) {
Text("BANKER")
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(3)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text("PAYS 0.95 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
.foregroundStyle(.white)
}
// Chip overlaid on right side
.overlay(alignment: .trailing) {
if betAmount > 0 {
ChipOnTable(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding)
}
}
}
.buttonStyle(.plain)
}
}
/// The PLAYER betting zone at the bottom of the table.
struct PlayerBettingZone: View {
let betAmount: Int
let isSelected: Bool
let isEnabled: Bool
var isAtMax: Bool = false
let action: () -> Void
// MARK: - Fixed Font Sizes
// Fixed because betting zones have strict space constraints
private let titleFontSize: CGFloat = Design.BaseFontSize.large
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.medium
private let chipTrailingPadding = Design.Spacing.medium
private let selectionShadowRadius = Design.Shadow.radiusSmall
// MARK: - Computed Properties
private var backgroundColors: [Color] {
isAtMax
? [Color.BettingZone.playerMaxLight, Color.BettingZone.playerMaxDark]
: [Color.BettingZone.playerLight, Color.BettingZone.playerDark]
}
private var borderColor: Color {
isAtMax ? Color.Border.silver : Color.Border.gold
}
// MARK: - Body
var body: some View {
Button {
if isEnabled {
action()
}
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(
LinearGradient(
colors: backgroundColors,
startPoint: .top,
endPoint: .bottom
)
)
// Selection glow
if isSelected {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
.shadow(color: .yellow.opacity(Design.Opacity.medium), radius: selectionShadowRadius)
}
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
// Centered text content
VStack(spacing: Design.Spacing.xxSmall) {
Text("PLAYER")
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(3)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text("PAYS 1 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
.foregroundStyle(.white)
}
// Chip overlaid on right side
.overlay(alignment: .trailing) {
if betAmount > 0 {
ChipOnTable(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding)
}
}
}
.buttonStyle(.plain)
}
}
#Preview {
ZStack {
Color.Table.baseDark
.ignoresSafeArea()
MiniBaccaratTableView(
gameState: GameState(),
selectedChip: .hundred
)
.padding()
}
}