CasinoGames/Baccarat/Views/GameTableView.swift

841 lines
32 KiB
Swift

//
// GameTableView.swift
// Baccarat
//
// The main baccarat table view with all game elements.
//
import SwiftUI
import CasinoKit
/// 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
@State private var showRules = false
@State private var showStats = 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 },
onHelp: { showRules = true },
onStats: { showStats = true }
)
Spacer(minLength: Design.Spacing.xSmall)
// 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: Design.Spacing.xSmall)
// Road map history
if settings.showHistory && !state.roundHistory.isEmpty {
RoadMapView(results: state.recentResults)
.padding(.horizontal)
}
Spacer(minLength: Design.Spacing.small)
// Mini Baccarat betting table
MiniBaccaratTableView(
gameState: state,
selectedChip: selectedChip
)
.padding(.horizontal, Design.Spacing.medium)
Spacer(minLength: Design.Spacing.medium)
// Chip selector - shows higher chips as you win more!
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
maxBet: state.maxBet
)
Spacer(minLength: Design.Spacing.small)
// Action buttons
ActionButtonsView(
gameState: state,
onDeal: {
Task {
await state.deal()
}
},
onClear: { state.clearBets() },
onNewRound: { state.newRound() }
)
.padding(.horizontal)
.padding(.bottom, Design.Spacing.xSmall)
}
.safeAreaPadding(.bottom)
// Result banner overlay
if state.showResultBanner, let result = state.lastResult {
ResultBannerView(
result: result,
totalWinnings: state.lastWinnings,
betResults: state.betResults,
playerHadPair: state.playerHadPair,
bankerHadPair: state.bankerHadPair,
currentBalance: state.balance,
minBet: state.minBet,
onNewRound: { state.newRound() },
onGameOver: {
// Reset game (sound already played when banner appeared)
state.resetGame()
}
)
.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()
}
}
.fullScreenCover(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
StatisticsSheetView(results: state.roundHistory)
}
}
}
/// 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(Design.Opacity.subtle))
.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(Design.Opacity.hint)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: Design.LineWidth.medium
)
)
)
.shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge)
.padding(.horizontal, Design.Spacing.xxLarge)
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showContent ? 1.0 : 0)
}
.onAppear {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
showContent = true
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Game Over"))
.accessibilityAddTraits(.isModal)
}
}
/// 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: - Fixed font sizes for card area
// Fixed because the card display has strict layout constraints
private let labelFontSize: CGFloat = 14
// MARK: - Accessibility
private var playerHandDescription: String {
if playerCards.isEmpty {
return String(localized: "No cards")
}
let visibleCards = zip(playerCards, playerCardsFaceUp)
.filter { $1 }
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
if visibleCards.isEmpty {
return String(localized: "Cards face down")
}
let format = String(localized: "handValueFormat")
return visibleCards.joined(separator: ", ") + ". " + String(format: format, playerValue)
}
private var bankerHandDescription: String {
if bankerCards.isEmpty {
return String(localized: "No cards")
}
let visibleCards = zip(bankerCards, bankerCardsFaceUp)
.filter { $1 }
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
if visibleCards.isEmpty {
return String(localized: "Cards face down")
}
let format = String(localized: "handValueFormat")
return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue)
}
var body: some View {
HStack(spacing: Design.Spacing.xxxLarge) {
// Player side
VStack(spacing: Design.Spacing.small) {
// Label with value
HStack(spacing: Design.Spacing.small) {
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
)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Player hand"))
.accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : ""))
// Banker side
VStack(spacing: Design.Spacing.small) {
// Label with value
HStack(spacing: Design.Spacing.small) {
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
)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Banker hand"))
.accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : ""))
}
.padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.xLarge)
.padding(.horizontal, Design.Spacing.xLarge)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
.fill(Color.black.opacity(Design.Opacity.quarter))
.accessibilityHidden(true)
)
.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
private let cardOverlap: CGFloat = -12
private let placeholderSpacing: CGFloat = 8
var body: some View {
HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) {
if cards.isEmpty {
// Placeholders - no overlap, just side by side
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(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.strokeBorder(
isWinner ? Color.yellow : Color.clear,
lineWidth: Design.LineWidth.standard
)
)
.overlay(alignment: .bottom) {
if isWinner {
Text("WIN")
.font(.system(size: winBadgeFontSize, weight: .black))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.yellow)
)
.offset(y: Design.Spacing.small)
}
}
}
}
/// 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.Table.baseDark
// Radial gradient for depth
RadialGradient(
colors: [
Color.Table.backgroundLight,
Color.Table.backgroundDark
],
center: .center,
startRadius: 50,
endRadius: 500
)
// Subtle felt texture
FeltPatternView()
.opacity(Design.Opacity.subtle / 3)
}
.ignoresSafeArea()
.accessibilityHidden(true) // Decorative element
}
}
/// 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
let onHelp: () -> Void
let onStats: () -> Void
// MARK: - Environment
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
/// Whether the current text size is an accessibility size (very large)
private var isAccessibilitySize: Bool {
dynamicTypeSize.isAccessibilitySize
}
// MARK: - Fixed font sizes for constrained top bar
// These use fixed sizes because the top bar has strict space constraints
// and must remain readable at all accessibility settings
private let labelFontSize: CGFloat = 9
private let currencyFontSize: CGFloat = 14
private let balanceFontSize: CGFloat = 20
private let smallFontSize: CGFloat = 12
private let buttonFontSize: CGFloat = 16
var body: some View {
HStack {
// Balance display - simplified at accessibility sizes
HStack(spacing: Design.Spacing.xSmall) {
Text("$")
.font(.system(size: currencyFontSize, weight: .bold))
.foregroundStyle(.yellow.opacity(Design.Opacity.heavy))
Text(balance, format: .number)
.font(.system(size: balanceFontSize, weight: .black, design: .rounded))
.foregroundStyle(.white)
.contentTransition(.numericText())
.animation(.spring(duration: Design.Animation.quick), value: balance)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(Color.black.opacity(Design.Opacity.overlay))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Balance"))
.accessibilityValue("$\(balance.formatted())")
Spacer()
// Cards remaining indicator - hidden at accessibility sizes to save space
if showCardsRemaining && !isAccessibilitySize {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "rectangle.portrait.on.rectangle.portrait.fill")
.font(.system(size: smallFontSize))
Text("\(cardsRemaining)")
.font(.system(size: smallFontSize, weight: .medium))
}
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Cards remaining in shoe"))
.accessibilityValue("\(cardsRemaining)")
Spacer()
}
// Statistics button
Button("Statistics", systemImage: "chart.bar.fill", action: onStats)
.labelStyle(.iconOnly)
.font(.system(size: buttonFontSize))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.padding(Design.Spacing.small)
.background(
Circle()
.fill(Color.black.opacity(Design.Opacity.overlay))
)
.accessibilityHint(String(localized: "View detailed game statistics"))
// Help/Rules button
Button("Help", systemImage: "info.circle.fill", action: onHelp)
.labelStyle(.iconOnly)
.font(.system(size: buttonFontSize))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.padding(Design.Spacing.small)
.background(
Circle()
.fill(Color.black.opacity(Design.Opacity.overlay))
)
// Settings button (icon only)
Button("Settings", systemImage: "gearshape.fill", action: onSettings)
.labelStyle(.iconOnly)
.font(.system(size: buttonFontSize))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.padding(Design.Spacing.small)
.background(
Circle()
.fill(Color.black.opacity(Design.Opacity.overlay))
)
// Reset button (icon only to save space)
Button("Reset", systemImage: "arrow.counterclockwise", action: onReset)
.labelStyle(.iconOnly)
.font(.system(size: buttonFontSize))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.padding(Design.Spacing.small)
.background(
Circle()
.fill(Color.black.opacity(Design.Opacity.overlay))
)
}
.padding(.horizontal)
.padding(.top, Design.Spacing.xSmall)
}
}
/// 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: - Environment
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
/// Whether the current text size is an accessibility size (very large)
private var isAccessibilitySize: Bool {
dynamicTypeSize.isAccessibilitySize
}
// MARK: - Fixed font sizes for action buttons
// Fixed because buttons have constrained space and must remain usable
private let buttonFontSize: CGFloat = Design.BaseFontSize.xLarge
private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall
private let statusFontSize: CGFloat = Design.BaseFontSize.medium
var body: some View {
HStack(spacing: Design.Spacing.medium) {
if gameState.currentPhase == .betting {
// Clear bets button - icon only at accessibility sizes
clearButton
// Deal button - icon only at accessibility sizes
dealButton
} else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner {
// New round button - only shown after banner is dismissed
// (The banner itself has a New Round button)
newRoundButton
} else if !gameState.showResultBanner {
// Playing indicator
HStack(spacing: Design.Spacing.xSmall) {
ProgressView()
.tint(.white)
.scaleEffect(0.8)
Text("Dealing...")
.font(.system(size: statusFontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.relaxed)
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
}
}
}
@ViewBuilder
private var clearButton: some View {
if isAccessibilitySize {
Button("Clear", systemImage: "xmark.circle", action: onClear)
.labelStyle(.iconOnly)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(.white)
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Circle()
.fill(Color.Button.destructive)
)
.opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0)
.disabled(gameState.currentBets.isEmpty)
} else {
Button("Clear", systemImage: "xmark.circle", action: onClear)
.labelStyle(.titleOnly)
.font(.system(size: buttonFontSize, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.Button.destructive)
)
.opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0)
.disabled(gameState.currentBets.isEmpty)
}
}
@ViewBuilder
private var dealButton: some View {
if isAccessibilitySize {
Button("Deal", systemImage: "play.fill", action: onDeal)
.labelStyle(.iconOnly)
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(.black)
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Circle()
.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 {
Button("Deal", systemImage: "play.fill", action: onDeal)
.labelStyle(.titleOnly)
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.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)
}
}
@ViewBuilder
private var newRoundButton: some View {
if isAccessibilitySize {
Button("New Round", systemImage: "arrow.right.circle", action: onNewRound)
.labelStyle(.iconOnly)
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(.black)
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Circle()
.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 {
Button("New Round", systemImage: "arrow.right.circle", action: onNewRound)
.labelStyle(.titleOnly)
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.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)
}
}
}
#Preview {
GameTableView()
}