554 lines
18 KiB
Swift
554 lines
18 KiB
Swift
//
|
|
// MiniBaccaratTableView.swift
|
|
// Baccarat
|
|
//
|
|
// A modern baccarat table layout with all betting options.
|
|
//
|
|
|
|
import SwiftUI
|
|
import CasinoKit
|
|
|
|
/// The baccarat betting table layout with main bets and side bets.
|
|
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.small) {
|
|
// 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 betting table
|
|
VStack(spacing: 0) {
|
|
// 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) }
|
|
)
|
|
|
|
// Divider
|
|
Rectangle()
|
|
.fill(Color.Border.gold.opacity(0.5))
|
|
.frame(height: 1)
|
|
|
|
// Middle row: BANKER | BONUS
|
|
MainBetRow(
|
|
title: "BANKER",
|
|
payoutText: "0.95 : 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.bankerDark,
|
|
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
|
|
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
|
|
)
|
|
|
|
// Divider
|
|
Rectangle()
|
|
.fill(Color.Border.gold.opacity(0.5))
|
|
.frame(height: 1)
|
|
|
|
// Bottom row: PLAYER | BONUS
|
|
MainBetRow(
|
|
title: "PLAYER",
|
|
payoutText: "1 : 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.playerDark,
|
|
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
|
|
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
|
|
)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
|
.strokeBorder(
|
|
LinearGradient(
|
|
colors: [Color.Border.goldLight, Color.Border.goldDark],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
),
|
|
lineWidth: Design.LineWidth.medium
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(0.3), radius: 10, y: 5)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 = 52
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
// B PAIR
|
|
PairBetZone(
|
|
title: "B PAIR",
|
|
betAmount: bankerPairAmount,
|
|
isEnabled: canBetBankerPair,
|
|
isAtMax: isBankerPairAtMax,
|
|
color: Color.BettingZone.bankerDark.opacity(0.6),
|
|
action: onBankerPair
|
|
)
|
|
|
|
// Vertical divider
|
|
Rectangle()
|
|
.fill(Color.Border.gold.opacity(0.5))
|
|
.frame(width: 1)
|
|
|
|
// TIE
|
|
TieBetZone(
|
|
betAmount: tieAmount,
|
|
isEnabled: canBetTie,
|
|
isAtMax: isTieAtMax,
|
|
action: onTie
|
|
)
|
|
|
|
// Vertical divider
|
|
Rectangle()
|
|
.fill(Color.Border.gold.opacity(0.5))
|
|
.frame(width: 1)
|
|
|
|
// P PAIR
|
|
PairBetZone(
|
|
title: "P PAIR",
|
|
betAmount: playerPairAmount,
|
|
isEnabled: canBetPlayerPair,
|
|
isAtMax: isPlayerPairAtMax,
|
|
color: Color.BettingZone.playerDark.opacity(0.6),
|
|
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 titleFontSize: CGFloat = 12
|
|
private let payoutFontSize: CGFloat = 10
|
|
|
|
var body: some View {
|
|
Button {
|
|
if isEnabled { action() }
|
|
} label: {
|
|
ZStack {
|
|
// Background
|
|
Rectangle()
|
|
.fill(color)
|
|
|
|
// Content
|
|
VStack(spacing: 2) {
|
|
Text(title)
|
|
.font(.system(size: titleFontSize, weight: .heavy, design: .rounded))
|
|
.foregroundStyle(.yellow)
|
|
|
|
Text("11 : 1")
|
|
.font(.system(size: payoutFontSize, weight: .medium, design: .rounded))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
|
|
// Chip indicator
|
|
if betAmount > 0 {
|
|
ChipBadge(amount: betAmount, isMax: isAtMax)
|
|
.offset(y: 16)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.5)
|
|
.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 titleFontSize: CGFloat = 14
|
|
private let payoutFontSize: CGFloat = 10
|
|
|
|
var body: some View {
|
|
Button {
|
|
if isEnabled { action() }
|
|
} label: {
|
|
ZStack {
|
|
// Background
|
|
Rectangle()
|
|
.fill(isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie)
|
|
|
|
// Content
|
|
VStack(spacing: 2) {
|
|
Text("TIE")
|
|
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
|
|
.tracking(1)
|
|
|
|
Text("8 : 1")
|
|
.font(.system(size: payoutFontSize, weight: .medium, design: .rounded))
|
|
.opacity(0.7)
|
|
}
|
|
.foregroundStyle(.white)
|
|
|
|
// Chip indicator
|
|
if betAmount > 0 {
|
|
ChipBadge(amount: betAmount, isMax: isAtMax)
|
|
.offset(y: 16)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.5)
|
|
.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 onMain: () -> Void
|
|
let onBonus: () -> Void
|
|
|
|
private let rowHeight: CGFloat = 65
|
|
private let bonusWidth: CGFloat = 80
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
// Main bet zone (BANKER or PLAYER)
|
|
MainBetZone(
|
|
title: title,
|
|
payoutText: payoutText,
|
|
betAmount: mainBetAmount,
|
|
isSelected: isSelected,
|
|
isEnabled: canBetMain,
|
|
isAtMax: isMainAtMax,
|
|
color: mainColor,
|
|
action: onMain
|
|
)
|
|
|
|
// Vertical divider
|
|
Rectangle()
|
|
.fill(Color.Border.gold.opacity(0.5))
|
|
.frame(width: 1)
|
|
|
|
// 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 color: Color
|
|
let action: () -> Void
|
|
|
|
private let titleFontSize: CGFloat = 20
|
|
private let payoutFontSize: CGFloat = 11
|
|
|
|
var body: some View {
|
|
Button {
|
|
if isEnabled { action() }
|
|
} label: {
|
|
ZStack {
|
|
// Background
|
|
Rectangle()
|
|
.fill(color)
|
|
|
|
// Selection highlight
|
|
if isSelected {
|
|
Rectangle()
|
|
.fill(Color.yellow.opacity(0.15))
|
|
|
|
Rectangle()
|
|
.strokeBorder(Color.yellow, lineWidth: 3)
|
|
}
|
|
|
|
// Content
|
|
HStack {
|
|
Spacer()
|
|
|
|
VStack(spacing: 3) {
|
|
Text(title)
|
|
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
|
|
.tracking(2)
|
|
|
|
Text(payoutText)
|
|
.font(.system(size: payoutFontSize, weight: .semibold, design: .rounded))
|
|
.opacity(0.7)
|
|
}
|
|
.foregroundStyle(.white)
|
|
|
|
Spacer()
|
|
|
|
// Chip indicator
|
|
if betAmount > 0 {
|
|
ChipOnTableView(amount: betAmount, showMax: isAtMax)
|
|
.padding(.trailing, Design.Spacing.medium)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.5)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(title) bet, pays \(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 titleFontSize: CGFloat = 9
|
|
private let diamondSize: CGFloat = 24
|
|
|
|
var body: some View {
|
|
Button {
|
|
if isEnabled { action() }
|
|
} label: {
|
|
ZStack {
|
|
// Background - darker purple/blue tint
|
|
Rectangle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.25, green: 0.3, blue: 0.45),
|
|
Color(red: 0.15, green: 0.2, blue: 0.35)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
|
|
// Content
|
|
VStack(spacing: 5) {
|
|
// Diamond shape
|
|
DiamondShape()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.purple.opacity(0.8), Color.purple.opacity(0.5)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.frame(width: diamondSize, height: diamondSize)
|
|
.overlay(
|
|
DiamondShape()
|
|
.strokeBorder(Color.white.opacity(0.4), lineWidth: 1)
|
|
)
|
|
|
|
Text("BONUS")
|
|
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
}
|
|
|
|
// Chip indicator
|
|
if betAmount > 0 {
|
|
ChipBadge(amount: betAmount, isMax: isAtMax)
|
|
.offset(y: 22)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(isEnabled ? 1.0 : 0.5)
|
|
.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: - Chip Badge (small indicator)
|
|
|
|
private struct ChipBadge: View {
|
|
let amount: Int
|
|
let isMax: Bool
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(isMax ? Color.gray : Color.yellow)
|
|
.frame(width: 20, height: 20)
|
|
|
|
Circle()
|
|
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1)
|
|
.frame(width: 20, height: 20)
|
|
|
|
if isMax {
|
|
Text("M")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
} else {
|
|
Text(formatCompact(amount))
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.black)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatCompact(_ value: Int) -> String {
|
|
if value >= 1000 {
|
|
return "\(value / 1000)K"
|
|
}
|
|
return "\(value)"
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ZStack {
|
|
Color.Table.baseDark
|
|
.ignoresSafeArea()
|
|
|
|
MiniBaccaratTableView(
|
|
gameState: GameState(),
|
|
selectedChip: .hundred
|
|
)
|
|
.padding()
|
|
}
|
|
}
|