465 lines
16 KiB
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(Design.MinScaleFactor.comfortable)
|
|
|
|
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(Design.MinScaleFactor.tight)
|
|
|
|
Text("PAYS 8 TO 1")
|
|
.font(.system(size: subtitleFontSize, weight: .medium))
|
|
.opacity(Design.Opacity.heavy)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
|
}
|
|
.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(Design.MinScaleFactor.tight)
|
|
|
|
Text("PAYS 0.95 TO 1")
|
|
.font(.system(size: subtitleFontSize, weight: .medium))
|
|
.opacity(Design.Opacity.heavy)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
|
}
|
|
.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(Design.MinScaleFactor.tight)
|
|
|
|
Text("PAYS 1 TO 1")
|
|
.font(.system(size: subtitleFontSize, weight: .medium))
|
|
.opacity(Design.Opacity.heavy)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
|
}
|
|
.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()
|
|
}
|
|
}
|