649 lines
23 KiB
Swift
649 lines
23 KiB
Swift
//
|
|
// GameTableView.swift
|
|
// Baccarat
|
|
//
|
|
// The main baccarat table view with all game elements.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// The main game table view containing all game elements.
|
|
struct GameTableView: View {
|
|
@State private var settings = GameSettings()
|
|
@State private var gameState: GameState?
|
|
@State private var selectedChip: ChipDenomination = .hundred
|
|
@State private var showSettings = false
|
|
|
|
private var state: GameState {
|
|
gameState ?? GameState(settings: settings)
|
|
}
|
|
|
|
private var playerIsWinner: Bool {
|
|
state.lastResult == .playerWins
|
|
}
|
|
|
|
private var bankerIsWinner: Bool {
|
|
state.lastResult == .bankerWins
|
|
}
|
|
|
|
private var isTie: Bool {
|
|
state.lastResult == .tie
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Table background
|
|
TableBackgroundView()
|
|
|
|
// Main content
|
|
VStack(spacing: 0) {
|
|
// Top bar with balance and info
|
|
TopBarView(
|
|
balance: state.balance,
|
|
cardsRemaining: state.engine.shoe.cardsRemaining,
|
|
showCardsRemaining: settings.showCardsRemaining,
|
|
onReset: { state.resetGame() },
|
|
onSettings: { showSettings = true }
|
|
)
|
|
|
|
Spacer(minLength: 4)
|
|
|
|
// Cards display area
|
|
CardsDisplayArea(
|
|
playerCards: state.visiblePlayerCards,
|
|
bankerCards: state.visibleBankerCards,
|
|
playerCardsFaceUp: state.playerCardsFaceUp,
|
|
bankerCardsFaceUp: state.bankerCardsFaceUp,
|
|
playerValue: state.playerHandValue,
|
|
bankerValue: state.bankerHandValue,
|
|
playerIsWinner: playerIsWinner,
|
|
bankerIsWinner: bankerIsWinner,
|
|
isTie: isTie
|
|
)
|
|
|
|
Spacer(minLength: 4)
|
|
|
|
// Road map history
|
|
if settings.showHistory && !state.roundHistory.isEmpty {
|
|
RoadMapView(results: state.recentResults)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
// Mini Baccarat betting table
|
|
MiniBaccaratTableView(
|
|
gameState: state,
|
|
selectedChip: selectedChip
|
|
)
|
|
.padding(.horizontal, 12)
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
// Chip selector - shows higher chips as you win more!
|
|
ChipSelectorView(
|
|
selectedChip: $selectedChip,
|
|
balance: state.balance,
|
|
maxBet: state.maxBet
|
|
)
|
|
.padding(.bottom, 12)
|
|
|
|
// Action buttons
|
|
ActionButtonsView(
|
|
gameState: state,
|
|
onDeal: {
|
|
Task {
|
|
await state.deal()
|
|
}
|
|
},
|
|
onClear: { state.clearBets() },
|
|
onNewRound: { state.newRound() }
|
|
)
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 4)
|
|
}
|
|
.safeAreaPadding(.bottom)
|
|
|
|
// Result banner overlay
|
|
if state.showResultBanner, let result = state.lastResult {
|
|
ResultBannerView(
|
|
result: result,
|
|
winnings: state.lastWinnings
|
|
)
|
|
.transition(.opacity)
|
|
|
|
// Confetti for wins
|
|
if state.lastWinnings > 0 {
|
|
ConfettiView()
|
|
}
|
|
}
|
|
|
|
// Game Over overlay when broke
|
|
if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating {
|
|
GameOverView(
|
|
roundsPlayed: state.roundHistory.count,
|
|
onPlayAgain: { state.resetGame() }
|
|
)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.onAppear {
|
|
if gameState == nil {
|
|
gameState = GameState(settings: settings)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView(settings: settings) {
|
|
// Apply settings when changed
|
|
gameState?.applySettings()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Game over screen shown when player runs out of money.
|
|
struct GameOverView: View {
|
|
let roundsPlayed: Int
|
|
let onPlayAgain: () -> Void
|
|
|
|
@State private var showContent = false
|
|
|
|
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
|
|
@ScaledMetric(relativeTo: .largeTitle) private var iconSize: CGFloat = Design.BaseFontSize.display
|
|
@ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle
|
|
@ScaledMetric(relativeTo: .body) private var messageFontSize: CGFloat = Design.BaseFontSize.xLarge
|
|
@ScaledMetric(relativeTo: .body) private var statsFontSize: CGFloat = 17
|
|
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.xLarge
|
|
|
|
// MARK: - Layout Constants
|
|
|
|
private let modalCornerRadius = Design.CornerRadius.xxxLarge
|
|
private let statsCornerRadius = Design.CornerRadius.large
|
|
private let cardPadding = Design.Spacing.xxxLarge
|
|
private let contentSpacing: CGFloat = 28
|
|
private let buttonHorizontalPadding: CGFloat = 48
|
|
private let buttonVerticalPadding: CGFloat = 18
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Solid dark backdrop - fully opaque
|
|
Color.black
|
|
.ignoresSafeArea()
|
|
|
|
// Modal card
|
|
VStack(spacing: contentSpacing) {
|
|
// Broke icon
|
|
Image(systemName: "creditcard.trianglebadge.exclamationmark")
|
|
.font(.system(size: iconSize))
|
|
.foregroundStyle(.red)
|
|
.symbolEffect(.pulse, options: .repeating)
|
|
|
|
// Title
|
|
Text("GAME OVER")
|
|
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
// Message
|
|
Text("You've run out of chips!")
|
|
.font(.system(size: messageFontSize, weight: .medium))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
|
|
|
// Stats card
|
|
VStack(spacing: Design.Spacing.medium) {
|
|
HStack {
|
|
Text("Rounds Played")
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
Spacer()
|
|
Text("\(roundsPlayed)")
|
|
.bold()
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
.font(.system(size: statsFontSize))
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: statsCornerRadius)
|
|
.fill(Color.white.opacity(0.08))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: statsCornerRadius)
|
|
.strokeBorder(Color.white.opacity(Design.Opacity.subtle), lineWidth: Design.LineWidth.thin)
|
|
)
|
|
)
|
|
.padding(.horizontal, Design.Spacing.xLarge)
|
|
|
|
// Play Again button
|
|
Button {
|
|
onPlayAgain()
|
|
} label: {
|
|
HStack(spacing: Design.Spacing.small) {
|
|
Image(systemName: "arrow.counterclockwise")
|
|
Text("Play Again")
|
|
}
|
|
.font(.system(size: buttonFontSize, weight: .bold))
|
|
.foregroundStyle(.black)
|
|
.padding(.horizontal, buttonHorizontalPadding)
|
|
.padding(.vertical, buttonVerticalPadding)
|
|
.background(
|
|
Capsule()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
)
|
|
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXLarge)
|
|
}
|
|
.padding(.top, Design.Spacing.medium)
|
|
}
|
|
.padding(cardPadding)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: modalCornerRadius)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.Modal.backgroundLight, Color.Modal.backgroundDark],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: modalCornerRadius)
|
|
.strokeBorder(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.red.opacity(Design.Opacity.medium),
|
|
Color.red.opacity(0.2)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: Design.LineWidth.medium
|
|
)
|
|
)
|
|
)
|
|
.shadow(color: .red.opacity(0.2), radius: Design.Shadow.radiusXXLarge)
|
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
|
.scaleEffect(showContent ? 1.0 : 0.8)
|
|
.opacity(showContent ? 1.0 : 0)
|
|
}
|
|
.onAppear {
|
|
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
|
|
showContent = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The cards display area showing both hands.
|
|
struct CardsDisplayArea: View {
|
|
let playerCards: [Card]
|
|
let bankerCards: [Card]
|
|
let playerCardsFaceUp: [Bool]
|
|
let bankerCardsFaceUp: [Bool]
|
|
let playerValue: Int
|
|
let bankerValue: Int
|
|
let playerIsWinner: Bool
|
|
let bankerIsWinner: Bool
|
|
let isTie: Bool
|
|
|
|
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
|
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = 14
|
|
|
|
var body: some View {
|
|
HStack(spacing: 32) {
|
|
// Player side
|
|
VStack(spacing: 10) {
|
|
// Label with value
|
|
HStack(spacing: 8) {
|
|
Text("PLAYER")
|
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
if !playerCards.isEmpty && playerCardsFaceUp.contains(true) {
|
|
ValueBadge(value: playerValue, color: .blue)
|
|
}
|
|
}
|
|
.frame(minHeight: 30)
|
|
|
|
// Cards
|
|
CompactHandView(
|
|
cards: playerCards,
|
|
cardsFaceUp: playerCardsFaceUp,
|
|
isWinner: playerIsWinner
|
|
)
|
|
}
|
|
|
|
// Banker side
|
|
VStack(spacing: 10) {
|
|
// Label with value
|
|
HStack(spacing: 8) {
|
|
Text("BANKER")
|
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
if !bankerCards.isEmpty && bankerCardsFaceUp.contains(true) {
|
|
ValueBadge(value: bankerValue, color: .red)
|
|
}
|
|
}
|
|
.frame(minHeight: 30)
|
|
|
|
// Cards
|
|
CompactHandView(
|
|
cards: bankerCards,
|
|
cardsFaceUp: bankerCardsFaceUp,
|
|
isWinner: bankerIsWinner
|
|
)
|
|
}
|
|
}
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 14)
|
|
.padding(.horizontal, 20)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(Color.black.opacity(0.25))
|
|
)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
/// A compact hand view showing cards in a row.
|
|
struct CompactHandView: View {
|
|
let cards: [Card]
|
|
let cardsFaceUp: [Bool]
|
|
let isWinner: Bool
|
|
|
|
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
|
|
@ScaledMetric(relativeTo: .caption) private var winBadgeFontSize: CGFloat = 10
|
|
|
|
// MARK: - Layout Constants
|
|
// Fixed size: cards have strict visual constraints
|
|
|
|
private let cardWidth: CGFloat = 45
|
|
|
|
var body: some View {
|
|
HStack(spacing: -12) {
|
|
if cards.isEmpty {
|
|
// Placeholders
|
|
ForEach(0..<2, id: \.self) { _ in
|
|
CardPlaceholderView(width: cardWidth)
|
|
}
|
|
} else {
|
|
ForEach(cards.indices, id: \.self) { index in
|
|
let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false
|
|
CardView(
|
|
card: cards[index],
|
|
isFaceUp: isFaceUp,
|
|
cardWidth: cardWidth
|
|
)
|
|
.zIndex(Double(index))
|
|
}
|
|
}
|
|
}
|
|
.padding(6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.strokeBorder(
|
|
isWinner ? Color.yellow : Color.clear,
|
|
lineWidth: 2
|
|
)
|
|
)
|
|
.overlay(alignment: .bottom) {
|
|
if isWinner {
|
|
Text("WIN")
|
|
.font(.system(size: winBadgeFontSize, weight: .black))
|
|
.foregroundStyle(.black)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.yellow)
|
|
)
|
|
.offset(y: 10)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A small badge showing the hand value.
|
|
struct ValueBadge: View {
|
|
let value: Int
|
|
let color: Color
|
|
|
|
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
|
|
@ScaledMetric(relativeTo: .headline) private var valueFontSize: CGFloat = 15
|
|
@ScaledMetric(relativeTo: .headline) private var badgeSize: CGFloat = 26
|
|
|
|
var body: some View {
|
|
Text("\(value)")
|
|
.font(.system(size: valueFontSize, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.frame(width: badgeSize, height: badgeSize)
|
|
.background(
|
|
Circle()
|
|
.fill(color)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The casino table background.
|
|
struct TableBackgroundView: View {
|
|
var body: some View {
|
|
ZStack {
|
|
// Base dark green
|
|
Color(red: 0.02, green: 0.15, blue: 0.08)
|
|
|
|
// Radial gradient for depth
|
|
RadialGradient(
|
|
colors: [
|
|
Color(red: 0.03, green: 0.25, blue: 0.12),
|
|
Color(red: 0.01, green: 0.12, blue: 0.06)
|
|
],
|
|
center: .center,
|
|
startRadius: 50,
|
|
endRadius: 500
|
|
)
|
|
|
|
// Subtle felt texture
|
|
FeltPatternView()
|
|
.opacity(0.03)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
|
|
/// Subtle felt texture pattern.
|
|
struct FeltPatternView: View {
|
|
var body: some View {
|
|
Canvas { context, size in
|
|
for _ in 0..<2000 {
|
|
let x = Double.random(in: 0...size.width)
|
|
let y = Double.random(in: 0...size.height)
|
|
let dotSize = Double.random(in: 1...2)
|
|
|
|
let rect = CGRect(x: x, y: y, width: dotSize, height: dotSize)
|
|
context.fill(Path(ellipseIn: rect), with: .color(.white))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top bar showing balance and game info.
|
|
struct TopBarView: View {
|
|
let balance: Int
|
|
let cardsRemaining: Int
|
|
let showCardsRemaining: Bool
|
|
let onReset: () -> Void
|
|
let onSettings: () -> Void
|
|
|
|
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
|
|
@ScaledMetric(relativeTo: .caption2) private var labelFontSize: CGFloat = 9
|
|
@ScaledMetric(relativeTo: .body) private var currencyFontSize: CGFloat = 14
|
|
@ScaledMetric(relativeTo: .title3) private var balanceFontSize: CGFloat = 20
|
|
@ScaledMetric(relativeTo: .caption) private var smallFontSize: CGFloat = 12
|
|
@ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = 16
|
|
|
|
var body: some View {
|
|
HStack {
|
|
// Balance display
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("BALANCE")
|
|
.font(.system(size: labelFontSize, weight: .medium, design: .rounded))
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
.tracking(1)
|
|
|
|
HStack(spacing: 4) {
|
|
Text("$")
|
|
.font(.system(size: currencyFontSize, weight: .bold))
|
|
.foregroundStyle(.yellow.opacity(0.8))
|
|
|
|
Text(balance, format: .number)
|
|
.font(.system(size: balanceFontSize, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.contentTransition(.numericText())
|
|
.animation(.spring(duration: 0.3), value: balance)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.black.opacity(0.4))
|
|
)
|
|
|
|
Spacer()
|
|
|
|
// Cards remaining indicator (if enabled)
|
|
if showCardsRemaining {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "rectangle.portrait.on.rectangle.portrait.fill")
|
|
.font(.system(size: smallFontSize))
|
|
Text("\(cardsRemaining)")
|
|
.font(.system(size: smallFontSize, weight: .medium))
|
|
}
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
|
|
Spacer()
|
|
}
|
|
|
|
// Settings button
|
|
Button("Settings", systemImage: "gearshape.fill", action: onSettings)
|
|
.labelStyle(.iconOnly)
|
|
.font(.system(size: buttonFontSize))
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
.padding(8)
|
|
.background(
|
|
Circle()
|
|
.fill(Color.black.opacity(0.4))
|
|
)
|
|
|
|
// Reset button
|
|
Button("Reset", systemImage: "arrow.counterclockwise", action: onReset)
|
|
.font(.system(size: smallFontSize, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.black.opacity(0.4))
|
|
)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
/// Action buttons for deal, clear, and new round.
|
|
struct ActionButtonsView: View {
|
|
@Bindable var gameState: GameState
|
|
let onDeal: () -> Void
|
|
let onClear: () -> Void
|
|
let onNewRound: () -> Void
|
|
|
|
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
|
|
@ScaledMetric(relativeTo: .body) private var clearButtonFontSize: CGFloat = 14
|
|
@ScaledMetric(relativeTo: .headline) private var primaryButtonFontSize: CGFloat = 16
|
|
@ScaledMetric(relativeTo: .body) private var statusFontSize: CGFloat = 14
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
if gameState.currentPhase == .betting {
|
|
// Clear bets button
|
|
Button("Clear", systemImage: "xmark.circle", action: onClear)
|
|
.font(.system(size: clearButtonFontSize, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.Button.destructive)
|
|
)
|
|
.opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0)
|
|
.disabled(gameState.currentBets.isEmpty)
|
|
|
|
// Deal button
|
|
Button("Deal", systemImage: "play.fill", action: onDeal)
|
|
.font(.system(size: primaryButtonFontSize, weight: .bold))
|
|
.foregroundStyle(.black)
|
|
.padding(.horizontal, 32)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
Capsule()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
)
|
|
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
|
.opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled)
|
|
.disabled(!gameState.canDeal)
|
|
} else if gameState.currentPhase == .roundComplete {
|
|
// New round button
|
|
Button("New Round", systemImage: "arrow.right.circle", action: onNewRound)
|
|
.font(.system(size: primaryButtonFontSize, weight: .bold))
|
|
.foregroundStyle(.black)
|
|
.padding(.horizontal, 32)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
Capsule()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
)
|
|
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
|
} else {
|
|
// Playing indicator
|
|
HStack(spacing: 6) {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.scaleEffect(0.8)
|
|
Text("Dealing...")
|
|
.font(.system(size: statusFontSize, weight: .medium))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
GameTableView()
|
|
}
|