843 lines
32 KiB
Swift
843 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) {
|
|
if let state = gameState {
|
|
SettingsView(settings: settings, gameState: state) {
|
|
// 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()
|
|
}
|