609 lines
21 KiB
Swift
609 lines
21 KiB
Swift
//
|
|
// MiniBaccaratTableView.swift
|
|
// Baccarat
|
|
//
|
|
// A realistic mini baccarat table layout for single player with side bets.
|
|
//
|
|
|
|
import SwiftUI
|
|
import CasinoKit
|
|
|
|
/// The semi-circular mini baccarat betting table layout.
|
|
struct MiniBaccaratTableView: View {
|
|
@Bindable var gameState: GameState
|
|
let selectedChip: ChipDenomination
|
|
|
|
// MARK: - Fixed Font Sizes
|
|
|
|
private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.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)
|
|
|
|
// Main table
|
|
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: 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Top Betting Row (B PAIR | TIE | P PAIR)
|
|
|
|
private struct TopBettingRow: View {
|
|
let bankerPairAmount: Int
|
|
let tieAmount: Int
|
|
let playerPairAmount: Int
|
|
let canBetBankerPair: Bool
|
|
let canBetTie: Bool
|
|
let canBetPlayerPair: Bool
|
|
let isBankerPairAtMax: Bool
|
|
let isTieAtMax: Bool
|
|
let isPlayerPairAtMax: Bool
|
|
let onBankerPair: () -> Void
|
|
let onTie: () -> Void
|
|
let onPlayerPair: () -> Void
|
|
|
|
private let rowHeight: CGFloat = 48
|
|
private let cornerRadius = Design.CornerRadius.small
|
|
|
|
var body: some View {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
// B PAIR
|
|
PairBetZone(
|
|
title: "B PAIR",
|
|
betAmount: bankerPairAmount,
|
|
isEnabled: canBetBankerPair,
|
|
isAtMax: isBankerPairAtMax,
|
|
color: Color.BettingZone.bankerLight,
|
|
action: onBankerPair
|
|
)
|
|
|
|
// TIE
|
|
TieBetZone(
|
|
betAmount: tieAmount,
|
|
isEnabled: canBetTie,
|
|
isAtMax: isTieAtMax,
|
|
action: onTie
|
|
)
|
|
|
|
// P PAIR
|
|
PairBetZone(
|
|
title: "P PAIR",
|
|
betAmount: playerPairAmount,
|
|
isEnabled: canBetPlayerPair,
|
|
isAtMax: isPlayerPairAtMax,
|
|
color: Color.BettingZone.playerLight,
|
|
action: onPlayerPair
|
|
)
|
|
}
|
|
.frame(height: rowHeight)
|
|
}
|
|
}
|
|
|
|
// MARK: - Pair Bet Zone
|
|
|
|
private struct PairBetZone: View {
|
|
let title: String
|
|
let betAmount: Int
|
|
let isEnabled: Bool
|
|
let isAtMax: Bool
|
|
let color: Color
|
|
let action: () -> Void
|
|
|
|
private let cornerRadius = Design.CornerRadius.small
|
|
private let titleFontSize: CGFloat = 11
|
|
private let payoutFontSize: CGFloat = 9
|
|
|
|
var body: some View {
|
|
Button {
|
|
if isEnabled { action() }
|
|
} 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)
|
|
|
|
// Content
|
|
VStack(spacing: 1) {
|
|
Text(title)
|
|
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.yellow)
|
|
|
|
Text("11:1")
|
|
.font(.system(size: payoutFontSize, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
}
|
|
|
|
// Chip indicator
|
|
if betAmount > 0 {
|
|
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
|
|
.offset(x: 0, y: 12)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.6)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(title) bet, pays 11 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
}
|
|
|
|
// MARK: - Tie Bet Zone
|
|
|
|
private struct TieBetZone: View {
|
|
let betAmount: Int
|
|
let isEnabled: Bool
|
|
let isAtMax: Bool
|
|
let action: () -> Void
|
|
|
|
private let cornerRadius = Design.CornerRadius.small
|
|
private let titleFontSize: CGFloat = 13
|
|
private let payoutFontSize: CGFloat = 9
|
|
|
|
var body: some View {
|
|
Button {
|
|
if isEnabled { action() }
|
|
} label: {
|
|
ZStack {
|
|
// Background
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.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) {
|
|
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)
|
|
}
|
|
.foregroundStyle(.white)
|
|
|
|
// Chip indicator
|
|
if betAmount > 0 {
|
|
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
|
|
.offset(x: 0, y: 12)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.6)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("Tie bet, pays 8 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Bet Row (BANKER/PLAYER with BONUS)
|
|
|
|
private struct MainBetRow: View {
|
|
let title: String
|
|
let payoutText: String
|
|
let mainBetAmount: Int
|
|
let bonusBetAmount: Int
|
|
let isSelected: Bool
|
|
let canBetMain: Bool
|
|
let canBetBonus: Bool
|
|
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
|
|
|
|
var body: some View {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
// Main bet zone (BANKER or PLAYER)
|
|
MainBetZone(
|
|
title: title,
|
|
payoutText: payoutText,
|
|
betAmount: mainBetAmount,
|
|
isSelected: isSelected,
|
|
isEnabled: canBetMain,
|
|
isAtMax: isMainAtMax,
|
|
colorLight: mainColor,
|
|
colorDark: mainColorDark,
|
|
action: onMain
|
|
)
|
|
|
|
// Dragon Bonus zone
|
|
DragonBonusZone(
|
|
betAmount: bonusBetAmount,
|
|
isEnabled: canBetBonus,
|
|
isAtMax: isBonusAtMax,
|
|
action: onBonus
|
|
)
|
|
.frame(width: bonusWidth)
|
|
}
|
|
.frame(height: rowHeight)
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Bet Zone (BANKER or PLAYER)
|
|
|
|
private struct MainBetZone: View {
|
|
let title: String
|
|
let payoutText: String
|
|
let betAmount: Int
|
|
let isSelected: Bool
|
|
let isEnabled: Bool
|
|
let isAtMax: Bool
|
|
let colorLight: Color
|
|
let colorDark: Color
|
|
let action: () -> Void
|
|
|
|
private let cornerRadius = Design.CornerRadius.medium
|
|
private let titleFontSize: CGFloat = 18
|
|
private let payoutFontSize: CGFloat = 9
|
|
|
|
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
|
|
)
|
|
)
|
|
|
|
// Selection glow
|
|
if isSelected {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
|
|
.shadow(color: .yellow.opacity(0.5), radius: 6)
|
|
}
|
|
|
|
// 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)
|
|
|
|
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()
|
|
ChipOnTableView(amount: betAmount, showMax: isAtMax)
|
|
.padding(.trailing, Design.Spacing.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.6)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(title) bet, \(payoutText)" + (isSelected ? ", selected" : "") + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
}
|
|
|
|
// MARK: - Dragon Bonus Zone
|
|
|
|
private struct DragonBonusZone: View {
|
|
let betAmount: Int
|
|
let isEnabled: Bool
|
|
let isAtMax: Bool
|
|
let action: () -> Void
|
|
|
|
private let cornerRadius = Design.CornerRadius.medium
|
|
private let titleFontSize: CGFloat = 10
|
|
private let diamondSize: CGFloat = 20
|
|
|
|
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)
|
|
|
|
// Content
|
|
VStack(spacing: 4) {
|
|
// Diamond shape
|
|
DiamondShape()
|
|
.fill(Color.purple.opacity(0.8))
|
|
.frame(width: diamondSize, height: diamondSize)
|
|
.overlay(
|
|
DiamondShape()
|
|
.strokeBorder(Color.white.opacity(0.5), lineWidth: 1)
|
|
)
|
|
|
|
Text("BONUS")
|
|
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
// Chip indicator
|
|
if betAmount > 0 {
|
|
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
|
|
.offset(y: 18)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.6)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("Dragon Bonus, pays up to 30 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
}
|
|
|
|
// MARK: - Diamond Shape
|
|
|
|
private struct DiamondShape: InsettableShape {
|
|
var insetAmount: CGFloat = 0
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
|
|
|
|
path.move(to: CGPoint(x: insetRect.midX, y: insetRect.minY))
|
|
path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.midY))
|
|
path.addLine(to: CGPoint(x: insetRect.midX, y: insetRect.maxY))
|
|
path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.midY))
|
|
path.closeSubpath()
|
|
|
|
return path
|
|
}
|
|
|
|
func inset(by amount: CGFloat) -> some InsettableShape {
|
|
var shape = self
|
|
shape.insetAmount += amount
|
|
return shape
|
|
}
|
|
}
|
|
|
|
// MARK: - Small Chip Indicator
|
|
|
|
private struct SmallChipIndicator: View {
|
|
let amount: Int
|
|
let isMax: Bool
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(isMax ? Color.gray : Color.yellow)
|
|
.frame(width: 18, height: 18)
|
|
|
|
Circle()
|
|
.strokeBorder(Color.white, lineWidth: 1)
|
|
.frame(width: 18, height: 18)
|
|
|
|
if isMax {
|
|
Text("M")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
} else {
|
|
Text(amount.formatted(.number.notation(.compactName)))
|
|
.font(.system(size: 6, 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
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ZStack {
|
|
Color.Table.baseDark
|
|
.ignoresSafeArea()
|
|
|
|
MiniBaccaratTableView(
|
|
gameState: GameState(),
|
|
selectedChip: .hundred
|
|
)
|
|
.padding()
|
|
}
|
|
}
|