514 lines
17 KiB
Swift
514 lines
17 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: - Layout Constants
|
|
|
|
private let tableLimitsFontSize = Design.FontSize.small
|
|
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: - Layout Constants
|
|
|
|
private let cornerRadius = Design.CornerRadius.small
|
|
private let titleFontSize = Design.FontSize.medium
|
|
private let subtitleFontSize = Design.FontSize.xSmall
|
|
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: - Layout Constants
|
|
|
|
private let cornerRadius = Design.CornerRadius.medium
|
|
private let titleFontSize = Design.FontSize.large
|
|
private let subtitleFontSize = Design.FontSize.xSmall
|
|
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: - Layout Constants
|
|
|
|
private let cornerRadius = Design.CornerRadius.medium
|
|
private let titleFontSize = Design.FontSize.large
|
|
private let subtitleFontSize = Design.FontSize.xSmall
|
|
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
|
|
|
|
private let chipSize = Design.Size.chipSmall
|
|
private let innerRingSize: CGFloat = 26
|
|
private let gradientEndRadius: CGFloat = 20
|
|
private let maxBadgeFontSize = Design.FontSize.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.FontSize.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()
|
|
}
|
|
}
|