CasinoGames/Baccarat/Views/MiniBaccaratTableView.swift

527 lines
18 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: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .caption) private var 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)
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: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .headline) private var titleFontSize: CGFloat = Design.BaseFontSize.medium
@ScaledMetric(relativeTo: .caption2) private var 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)
Text("PAYS 8 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
}
.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: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .headline) private var titleFontSize: CGFloat = Design.BaseFontSize.large
@ScaledMetric(relativeTo: .caption2) private var 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)
Text("PAYS 0.95 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
}
.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: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .headline) private var titleFontSize: CGFloat = Design.BaseFontSize.large
@ScaledMetric(relativeTo: .caption2) private var 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)
Text("PAYS 1 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
}
.foregroundStyle(.white)
}
// Chip overlaid on right side
.overlay(alignment: .trailing) {
if betAmount > 0 {
ChipOnTable(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding)
}
}
}
.buttonStyle(.plain)
}
}
/// A chip displayed on the table showing bet amount.
struct ChipOnTable: View {
let amount: Int
var showMax: Bool = false
// MARK: - Layout Constants
// Fixed sizes: chip face has strict space constraints
private let chipSize = Design.Size.chipSmall
private let innerRingSize: CGFloat = 26
private let gradientEndRadius: CGFloat = 20
private let maxBadgeFontSize = Design.BaseFontSize.xxSmall
private let maxBadgeOffsetX: CGFloat = 6
private let maxBadgeOffsetY: CGFloat = -4
// MARK: - Computed Properties
private var chipColor: Color {
switch amount {
case 0..<50: return .blue
case 50..<100: return .orange
case 100..<500: return .black
case 500..<1000: return .purple
default: return Color.Chip.gold
}
}
private var displayText: String {
amount >= 1000 ? "\(amount / 1000)K" : "\(amount)"
}
private var textFontSize: CGFloat {
amount >= 1000 ? Design.BaseFontSize.small : 11
}
// MARK: - Body
var body: some View {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [chipColor.opacity(0.9), chipColor],
center: .topLeading,
startRadius: 0,
endRadius: gradientEndRadius
)
)
.frame(width: chipSize, height: chipSize)
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.heavy), lineWidth: Design.LineWidth.medium)
.frame(width: chipSize, height: chipSize)
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
.frame(width: innerRingSize, height: innerRingSize)
Text(displayText)
.font(.system(size: textFontSize, weight: .bold))
.foregroundStyle(.white)
}
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, x: 1, y: 2)
.overlay(alignment: .topTrailing) {
if showMax {
Text("MAX")
.font(.system(size: maxBadgeFontSize, weight: .black))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.red)
)
.offset(x: maxBadgeOffsetX, y: maxBadgeOffsetY)
}
}
}
}
#Preview {
ZStack {
Color.Table.baseDark
.ignoresSafeArea()
MiniBaccaratTableView(
gameState: GameState(),
selectedChip: .hundred
)
.padding()
}
}