diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 7353cca..c503532 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -120,6 +120,10 @@ "comment" : "A bullet point in the \"How to Export Icons\" section, describing how to use an online tool to generate all sizes for an app icon.", "isCommentAutoGenerated" : true }, + "↓ then →" : { + "comment" : "A textual instruction for using the road map in the game.", + "isCommentAutoGenerated" : true + }, "+%lld" : { "comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.", "localizations" : { @@ -166,6 +170,7 @@ }, "$" : { "comment" : "The currency symbol \"$\".", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -207,6 +212,7 @@ }, "$%@" : { "comment" : "The value of the balance displayed in the top bar.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -677,6 +683,7 @@ }, "Balance" : { "comment" : "A label describing the user's current balance.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1215,6 +1222,7 @@ }, "Cards remaining in shoe" : { "comment" : "A label describing the number of cards remaining in the shoe.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2123,6 +2131,7 @@ }, "Help" : { "comment" : "The label of a button that shows help information.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3423,6 +3432,7 @@ }, "Reset" : { "comment" : "A button that resets the game to its initial state.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4453,6 +4463,7 @@ }, "View detailed game statistics" : { "comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Baccarat/Baccarat/Theme/DesignConstants.swift b/Baccarat/Baccarat/Theme/DesignConstants.swift index fc95199..3dbfce9 100644 --- a/Baccarat/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Baccarat/Theme/DesignConstants.swift @@ -13,6 +13,9 @@ import CasinoKit /// Shared constants are imported from CasinoDesign; game-specific values are defined here. enum Design { + /// Set to true to show layout debug borders on views + static let showDebugBorders = false + // MARK: - Shared Constants (from CasinoKit) typealias Spacing = CasinoDesign.Spacing @@ -34,7 +37,7 @@ enum Design { /// Hand scaling factor for cards and related elements. /// 1.0 = original size, 1.5 = 50% larger, 2.0 = double size. /// Adjust this value to change card sizes across the app. - static let handScale: CGFloat = 1.5 + static let handScale: CGFloat = 1.75 /// Scale multiplier for small screens (iPhone SE, etc). /// Applied instead of handScale on screens narrower than smallScreenThreshold. @@ -56,11 +59,16 @@ enum Design { /// Base card width before scaling (for reference) private static let cardWidthTableBase: CGFloat = 45 + /// Base card overlap before scaling. + /// More negative = more overlap (less card visible). + /// -15 is default, -20 shows less card, -25 shows even less. + private static let cardOverlapBase: CGFloat = -25 + /// Card overlap scaled with hand size (standard iPhone) - static let cardOverlap: CGFloat = CasinoDesign.Size.cardOverlap * handScale + static let cardOverlap: CGFloat = cardOverlapBase * handScale /// Card overlap for small screens - static let cardOverlapSmall: CGFloat = CasinoDesign.Size.cardOverlap * smallScreenScale + static let cardOverlapSmall: CGFloat = cardOverlapBase * smallScreenScale // Baccarat table cards - scaled for better visibility (standard iPhone) static let cardWidthTable: CGFloat = cardWidthTableBase * handScale @@ -72,7 +80,7 @@ enum Design { static let cardWidthTableLarge: CGFloat = cardWidthTableBase * handScale * largeScreenMultiplier /// Card overlap for large screens - static let cardOverlapLarge: CGFloat = CasinoDesign.Size.cardOverlap * handScale * largeScreenMultiplier + static let cardOverlapLarge: CGFloat = cardOverlapBase * handScale * largeScreenMultiplier // Chips - use CasinoDesign values static let chipSmall: CGFloat = CasinoDesign.Size.chipSmall diff --git a/Baccarat/Baccarat/Views/BrandingPreviewView.swift b/Baccarat/Baccarat/Views/Development/BrandingPreviewView.swift similarity index 100% rename from Baccarat/Baccarat/Views/BrandingPreviewView.swift rename to Baccarat/Baccarat/Views/Development/BrandingPreviewView.swift diff --git a/Baccarat/Baccarat/Views/IconGeneratorView.swift b/Baccarat/Baccarat/Views/Development/IconGeneratorView.swift similarity index 100% rename from Baccarat/Baccarat/Views/IconGeneratorView.swift rename to Baccarat/Baccarat/Views/Development/IconGeneratorView.swift diff --git a/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift b/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift new file mode 100644 index 0000000..55f0203 --- /dev/null +++ b/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift @@ -0,0 +1,201 @@ +// +// ActionButtonsView.swift +// Baccarat +// +// Action buttons for deal, clear, and new round. +// + +import SwiftUI +import CasinoKit + +/// 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: - Layout Constants + + private let buttonFontSize: CGFloat = Design.BaseFontSize.xLarge + private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall + private let statusFontSize: CGFloat = Design.BaseFontSize.medium + private let containerHeight: CGFloat = 50 + + // MARK: - Body + + var body: some View { + ZStack { + // Fixed height container to prevent layout shifts + Color.clear + .frame(height: containerHeight) + + // Content changes with animation + Group { + if gameState.currentPhase == .betting { + bettingButtons + } else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner { + newRoundButton + } else if !gameState.showResultBanner { + dealingIndicator + } + } + .animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase) + .animation(.easeInOut(duration: Design.Animation.quick), value: gameState.showResultBanner) + } + } + + // MARK: - Private Views + + private var bettingButtons: some View { + HStack(spacing: Design.Spacing.medium) { + clearButton + dealButton + } + } + + private var dealingIndicator: some View { + 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(.titleAndIcon) + .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 { + let buttonBackground = LinearGradient( + colors: [Color.Button.goldLight, Color.Button.goldDark], + startPoint: .top, + endPoint: .bottom + ) + + 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(buttonBackground) + ) + .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(.titleAndIcon) + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) + .background( + Capsule() + .fill(buttonBackground) + ) + .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 { + let buttonBackground = LinearGradient( + colors: [Color.Button.goldLight, Color.Button.goldDark], + startPoint: .top, + endPoint: .bottom + ) + + if isAccessibilitySize { + Button("New Round", systemImage: "arrow.clockwise", action: onNewRound) + .labelStyle(.iconOnly) + .font(.system(size: iconSize, weight: .bold)) + .foregroundStyle(.black) + .padding(Design.Spacing.medium + Design.Spacing.xxSmall) + .background( + Circle() + .fill(buttonBackground) + ) + .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) + } else { + Button("New Round", systemImage: "arrow.clockwise", action: onNewRound) + .labelStyle(.titleAndIcon) + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) + .background( + Capsule() + .fill(buttonBackground) + ) + .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) + } + } +} + +// MARK: - Previews + +#Preview("Betting Phase") { + ZStack { + TableBackgroundView() + VStack { + Spacer() + ActionButtonsView( + gameState: GameState(settings: GameSettings()), + onDeal: {}, + onClear: {}, + onNewRound: {} + ) + .padding() + } + } +} + diff --git a/Baccarat/Baccarat/Views/Game/GameTableView.swift b/Baccarat/Baccarat/Views/Game/GameTableView.swift new file mode 100644 index 0000000..6fee835 --- /dev/null +++ b/Baccarat/Baccarat/Views/Game/GameTableView.swift @@ -0,0 +1,384 @@ +// +// 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 + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + // MARK: - Computed Properties + + /// Whether we're on iPad or large screen + private var isLargeScreen: Bool { + horizontalSizeClass == .regular + } + + /// Whether we're in landscape mode (compact vertical on iPad) + private var isLandscape: Bool { + verticalSizeClass == .compact + } + + /// Extra bottom padding for landscape mode where vertical space is tight + private var bottomPadding: CGFloat { + isLandscape ? Design.Spacing.medium : Design.Spacing.xSmall + } + + /// Minimum spacer height - smaller in landscape to fit content + private var minSpacerHeight: CGFloat { + isLandscape ? 0 : Design.Spacing.xSmall + } + + /// Smaller spacer height - reduced in landscape + private var smallSpacerHeight: CGFloat { + isLandscape ? Design.Spacing.xxSmall : Design.Spacing.small + } + + /// Medium spacer height - reduced in landscape + private var mediumSpacerHeight: CGFloat { + isLandscape ? Design.Spacing.xSmall : Design.Spacing.medium + } + + /// Maximum width for game content on large screens + private var maxContentWidth: CGFloat { + isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait + } + + 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 + } + + // Use global debug flag from Design constants + private var showDebugBorders: Bool { Design.showDebugBorders } + + // MARK: - Body + + var body: some View { + GeometryReader { geometry in + ZStack { + // Table background (from CasinoKit) + TableBackgroundView() + + // Main content + mainContent(geometry: geometry) + + // Overlays + overlays + } + } + .onAppear { + if gameState == nil { + gameState = GameState(settings: settings) + } + } + .sheet(isPresented: $showSettings) { + if let state = gameState { + SettingsView(settings: settings, gameState: state) { + gameState?.applySettings() + } + } + } + .fullScreenCover(isPresented: $showRules) { + RulesHelpView() + } + .sheet(isPresented: $showStats) { + StatisticsSheetView(results: state.roundHistory) + } + } + + // MARK: - Private Views + + @ViewBuilder + private func mainContent(geometry: GeometryProxy) -> some View { + let screenWidth = geometry.size.width + let screenHeight = geometry.size.height + // Use geometry to detect landscape on iPad (width > height and large screen) + let isLandscapeLayout = isLargeScreen && screenWidth > screenHeight + + if isLandscapeLayout { + // Landscape iPad: RoadMap on left, game content on right + landscapeLayout(screenWidth: screenWidth) + } else { + // Portrait or iPhone: vertical stack with RoadMap inline + portraitLayout(screenWidth: screenWidth) + } + } + + /// Landscape layout with TopBar spanning full width, RoadMap grid on left below TopBar + private func landscapeLayout(screenWidth: CGFloat) -> some View { + VStack(spacing: 0) { + // Top bar spans full width + TopBarView( + balance: state.balance, + secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil, + secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil, + onReset: { state.resetGame() }, + onSettings: { showSettings = true }, + onHelp: { showRules = true }, + onStats: { showStats = true } + ) + .debugBorder(showDebugBorders, color: .cyan, label: "TopBar") + + // Main content area with optional sidebar + HStack(spacing: 0) { + // Left side: Road map history grid + if settings.showHistory && !state.roundHistory.isEmpty { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + // Header with reading instructions + VStack(alignment: .leading, spacing: 2) { + Text("HISTORY") + .font(.system(size: Design.BaseFontSize.small, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Text("↓ then →") + .font(.system(size: Design.BaseFontSize.xSmall, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(.horizontal, Design.Spacing.small) + .padding(.top, Design.Spacing.small) + + // Grid-based road map (rows calculated dynamically) + RoadMapGridView( + results: state.recentResults, + dotSize: 32 + ) + .frame(maxHeight: .infinity) + + Spacer(minLength: 0) + } + .frame(width: 240) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.light)) + .padding(Design.Spacing.xSmall) + ) + .debugBorder(showDebugBorders, color: .orange, label: "RoadMap") + } + + // Right side: Main game content + VStack(spacing: 0) { + Spacer(minLength: 0) + + // 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, + screenWidth: screenWidth + ) + .frame(maxWidth: maxContentWidth) + .debugBorder(showDebugBorders, color: .red, label: "CardsArea") + + Spacer(minLength: 0) + + // Betting table + BettingTableView( + gameState: state, + selectedChip: selectedChip + ) + .frame(maxWidth: maxContentWidth) + .padding(.horizontal, Design.Spacing.medium) + .debugBorder(showDebugBorders, color: .blue, label: "BetTable") + + Spacer(minLength: Design.Spacing.xSmall) + + // Chip selector + ChipSelectorView( + selectedChip: $selectedChip, + balance: state.balance, + currentBet: state.totalBetAmount, + maxBet: state.maxBet + ) + .frame(maxWidth: maxContentWidth) + .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") + + Spacer(minLength: Design.Spacing.xSmall) + + // Action buttons + ActionButtonsView( + gameState: state, + onDeal: { + Task { + await state.deal() + } + }, + onClear: { state.clearBets() }, + onNewRound: { state.newRound() } + ) + .frame(maxWidth: maxContentWidth * 0.8) + .padding(.horizontal) + .padding(.bottom, Design.Spacing.small) + .debugBorder(showDebugBorders, color: .green, label: "ActionBtns") + } + .frame(maxWidth: .infinity) + .debugBorder(showDebugBorders, color: .white, label: "GameContent") + } + } + .safeAreaPadding(.bottom, Design.Spacing.small) + } + + /// Portrait layout with RoadMap inline + private func portraitLayout(screenWidth: CGFloat) -> some View { + VStack(spacing: 0) { + // Top bar with balance and info (from CasinoKit) + TopBarView( + balance: state.balance, + secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil, + secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil, + onReset: { state.resetGame() }, + onSettings: { showSettings = true }, + onHelp: { showRules = true }, + onStats: { showStats = true } + ) + .debugBorder(showDebugBorders, color: .cyan, label: "TopBar") + + Spacer(minLength: minSpacerHeight) + .debugBorder(showDebugBorders, color: .yellow, label: "Spacer1") + + // 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, + screenWidth: screenWidth + ) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) + .debugBorder(showDebugBorders, color: .red, label: "CardsArea") + + Spacer(minLength: minSpacerHeight) + .debugBorder(showDebugBorders, color: .yellow, label: "Spacer2") + + // Road map history + if settings.showHistory && !state.roundHistory.isEmpty { + RoadMapView(results: state.recentResults) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) + .padding(.horizontal) + .debugBorder(showDebugBorders, color: .orange, label: "RoadMap") + } + + Spacer(minLength: smallSpacerHeight) + .debugBorder(showDebugBorders, color: .yellow, label: "Spacer3") + + // Betting table + BettingTableView( + gameState: state, + selectedChip: selectedChip + ) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) + .padding(.horizontal, Design.Spacing.medium) + .debugBorder(showDebugBorders, color: .blue, label: "BetTable") + + Spacer(minLength: mediumSpacerHeight) + .debugBorder(showDebugBorders, color: .yellow, label: "Spacer4") + + // Chip selector (from CasinoKit) + ChipSelectorView( + selectedChip: $selectedChip, + balance: state.balance, + currentBet: state.totalBetAmount, + maxBet: state.maxBet + ) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) + .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") + + Spacer(minLength: smallSpacerHeight) + .debugBorder(showDebugBorders, color: .yellow, label: "Spacer5") + + // Action buttons + ActionButtonsView( + gameState: state, + onDeal: { + Task { + await state.deal() + } + }, + onClear: { state.clearBets() }, + onNewRound: { state.newRound() } + ) + .frame(maxWidth: isLargeScreen ? maxContentWidth * 0.8 : .infinity) + .padding(.horizontal) + .padding(.bottom, bottomPadding) + .debugBorder(showDebugBorders, color: .green, label: "ActionBtns") + } + .safeAreaPadding(.bottom) + .debugBorder(showDebugBorders, color: .white, label: "MainContent") + } + + @ViewBuilder + private var overlays: some View { + // 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: { + state.resetGame() + } + ) + .transition(.opacity) + + // Confetti for wins (from CasinoKit) + if state.lastWinnings > 0 { + ConfettiView() + } + } + + // Game Over overlay + if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating { + GameOverView( + roundsPlayed: state.roundHistory.count, + onPlayAgain: { state.resetGame() } + ) + .transition(.opacity) + } + } +} + +// MARK: - Previews + +#Preview("Game Table") { + GameTableView() +} diff --git a/Baccarat/Baccarat/Views/GameTableView.swift b/Baccarat/Baccarat/Views/GameTableView.swift deleted file mode 100644 index 0dcb260..0000000 --- a/Baccarat/Baccarat/Views/GameTableView.swift +++ /dev/null @@ -1,1003 +0,0 @@ -// -// 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 - - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.verticalSizeClass) private var verticalSizeClass - - /// Whether we're on iPad or large screen - private var isLargeScreen: Bool { - horizontalSizeClass == .regular - } - - /// Whether we're in landscape mode (compact vertical on iPad) - private var isLandscape: Bool { - verticalSizeClass == .compact - } - - /// Maximum width for game content on large screens - private var maxContentWidth: CGFloat { - isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait - } - - 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 { - GeometryReader { geometry in - 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 - constrained width on iPad - 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, - screenWidth: geometry.size.width - ) - .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) - - Spacer(minLength: Design.Spacing.xSmall) - - // Road map history - constrained width on iPad - if settings.showHistory && !state.roundHistory.isEmpty { - RoadMapView(results: state.recentResults) - .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) - .padding(.horizontal) - } - - Spacer(minLength: Design.Spacing.small) - - // Mini Baccarat betting table - constrained width on iPad - MiniBaccaratTableView( - gameState: state, - selectedChip: selectedChip - ) - .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) - .padding(.horizontal, Design.Spacing.medium) - - Spacer(minLength: Design.Spacing.medium) - - // Chip selector - constrained width on iPad - ChipSelectorView( - selectedChip: $selectedChip, - balance: state.balance, - currentBet: state.totalBetAmount, - maxBet: state.maxBet - ) - .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) - - Spacer(minLength: Design.Spacing.small) - - // Action buttons - constrained width on iPad - ActionButtonsView( - gameState: state, - onDeal: { - Task { - await state.deal() - } - }, - onClear: { state.clearBets() }, - onNewRound: { state.newRound() } - ) - .frame(maxWidth: isLargeScreen ? maxContentWidth * 0.8 : .infinity) - .padding(.horizontal) - .padding(.bottom, Design.Spacing.xSmall) - } - .safeAreaPadding(.bottom) - - // Result banner overlay (handles its own iPad sizing) - 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 (handles its own iPad sizing) - 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 - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - /// Maximum width for the modal card on iPad - private var maxModalWidth: CGFloat { - horizontalSizeClass == .regular ? Design.Size.maxModalWidth : .infinity - } - - // 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) - .frame(maxWidth: maxModalWidth) - .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 - /// Screen width for responsive card sizing - var screenWidth: CGFloat = 400 - - // MARK: - Environment - - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - // MARK: - Scaled font sizes for card area - // Scales with hand size for proportional appearance - - /// Whether we're on a large screen (iPad) - private var isLargeScreen: Bool { - horizontalSizeClass == .regular - } - - /// Label font size - only scales on iPad to avoid clipping on small iPhones - private var labelFontSize: CGFloat { - let baseSize: CGFloat = 14 - // Only apply scaling on large screens; keep original size on iPhone - return isLargeScreen ? baseSize * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseSize - } - - // 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) - } - - /// Minimum height for label row - only scales on iPad - private var labelRowMinHeight: CGFloat { - let baseHeight: CGFloat = 30 - // Only apply scaling on large screens; keep original size on iPhone - return isLargeScreen ? baseHeight * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseHeight - } - - /// Spacing between PLAYER and BANKER hands - reduced on smaller screens - private var handsSpacing: CGFloat { - isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.medium - } - - /// Horizontal padding inside the container - reduced on smaller screens - private var containerPaddingH: CGFloat { - isLargeScreen ? Design.Spacing.xLarge : Design.Spacing.small - } - - var body: some View { - HStack(spacing: handsSpacing) { - // 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: labelRowMinHeight) - - // Cards - CompactHandView( - cards: playerCards, - cardsFaceUp: playerCardsFaceUp, - isWinner: playerIsWinner, - screenWidth: screenWidth - ) - } - .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: labelRowMinHeight) - - // Cards - CompactHandView( - cards: bankerCards, - cardsFaceUp: bankerCardsFaceUp, - isWinner: bankerIsWinner, - screenWidth: screenWidth - ) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(String(localized: "Banker hand")) - .accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : "")) - } - .padding(.top, Design.Spacing.medium) - .padding(.bottom, Design.Spacing.large) - .padding(.horizontal, containerPaddingH) - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) - .fill(Color.black.opacity(Design.Opacity.quarter)) - .accessibilityHidden(true) - ) - .padding(.horizontal, Design.Spacing.small) - } -} - -/// A compact hand view showing cards in a row. -struct CompactHandView: View { - let cards: [Card] - let cardsFaceUp: [Bool] - let isWinner: Bool - /// Screen width passed from parent for responsive sizing - var screenWidth: CGFloat = 400 - - // MARK: - Environment - - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - // MARK: - Layout Constants - // Responsive sizing based on device - - /// Whether we're on a large screen (iPad) - private var isLargeScreen: Bool { - horizontalSizeClass == .regular - } - - /// Whether we're on a small screen (iPhone SE, etc) - private var isSmallScreen: Bool { - !isLargeScreen && screenWidth < Design.Size.smallScreenThreshold - } - - /// WIN badge font size - only scales on iPad - private var winBadgeFontSize: CGFloat { - let baseSize: CGFloat = 10 - return isLargeScreen ? baseSize * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseSize - } - - /// Card width - responsive based on screen size - private var cardWidth: CGFloat { - if isLargeScreen { - return Design.Size.cardWidthTableLarge - } else if isSmallScreen { - return Design.Size.cardWidthTableSmall - } else { - return Design.Size.cardWidthTable - } - } - - /// Card height based on aspect ratio - private var cardHeight: CGFloat { - cardWidth * Design.Size.cardAspectRatio - } - - /// Card overlap - scaled with card size - private var cardOverlap: CGFloat { - if isLargeScreen { - return Design.Size.cardOverlapLarge - } else if isSmallScreen { - return Design.Size.cardOverlapSmall - } else { - return Design.Size.cardOverlap - } - } - - private let placeholderSpacing: CGFloat = Design.Spacing.small - - /// Fixed container width to prevent resizing during deal - /// Calculated as: 3 cards with overlap + padding - /// = cardWidth + (cardWidth + overlap) * 2 + padding * 2 - private var fixedContainerWidth: CGFloat { - // Max 3 cards: first card full width + 2 more with overlap - let cardsWidth = cardWidth + (cardWidth + cardOverlap) * 2 - return cardsWidth + Design.Spacing.xSmall * 2 - } - - /// Fixed container height - private var fixedContainerHeight: CGFloat { - cardHeight + Design.Spacing.xSmall * 2 - } - - var body: some View { - ZStack { - // Fixed-size container - Color.clear - .frame(width: fixedContainerWidth, height: fixedContainerHeight) - - // Cards content centered in fixed container - 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)) - } - } - } - } - .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: - Environment - - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - // MARK: - Scaled Font Sizes (Dynamic Type) - - /// Whether we're on a large screen (iPad) - private var isLargeScreen: Bool { - horizontalSizeClass == .regular - } - - /// Scale factor for badge sizing - only applies on iPad to avoid clipping on iPhone - private var scale: CGFloat { - isLargeScreen ? Design.Size.handScale * Design.Size.largeScreenMultiplier : 1.0 - } - - @ScaledMetric(relativeTo: .headline) private var baseValueFontSize: CGFloat = 15 - @ScaledMetric(relativeTo: .headline) private var baseBadgeSize: CGFloat = 26 - - private var valueFontSize: CGFloat { baseValueFontSize * scale } - private var badgeSize: CGFloat { baseBadgeSize * scale } - - 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 - - /// Fixed height to prevent layout shifts when buttons change - private let containerHeight: CGFloat = 50 - - var body: some View { - ZStack { - // Fixed height container to prevent layout shifts - Color.clear - .frame(height: containerHeight) - - // Content changes with animation - Group { - if gameState.currentPhase == .betting { - // Clear bets button - icon only at accessibility sizes - HStack(spacing: Design.Spacing.medium) { - clearButton - 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) - } - } - .animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase) - .animation(.easeInOut(duration: Design.Animation.quick), value: gameState.showResultBanner) - } - } - - @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(.titleAndIcon) - .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(.titleAndIcon) - .font(.system(size: buttonFontSize, weight: .bold)) - .foregroundStyle(.black) - .padding(.horizontal, Design.Spacing.xxLarge) - .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.clockwise", 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.clockwise", action: onNewRound) - .labelStyle(.titleAndIcon) - .font(.system(size: buttonFontSize, weight: .bold)) - .foregroundStyle(.black) - .padding(.horizontal, Design.Spacing.xxLarge) - .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() -} diff --git a/Baccarat/Baccarat/Views/Sheets/GameOverView.swift b/Baccarat/Baccarat/Views/Sheets/GameOverView.swift new file mode 100644 index 0000000..46b8823 --- /dev/null +++ b/Baccarat/Baccarat/Views/Sheets/GameOverView.swift @@ -0,0 +1,189 @@ +// +// GameOverView.swift +// Baccarat +// +// Game over screen shown when player runs out of money. +// + +import SwiftUI +import CasinoKit + +/// Game over screen shown when player runs out of money. +struct GameOverView: View { + let roundsPlayed: Int + let onPlayAgain: () -> Void + + @State private var showContent = false + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + /// Maximum width for the modal card on iPad + private var maxModalWidth: CGFloat { + horizontalSizeClass == .regular ? Design.Size.maxModalWidth : .infinity + } + + // 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 + modalContent + } + .onAppear { + withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) { + showContent = true + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Game Over")) + .accessibilityAddTraits(.isModal) + } + + // MARK: - Private Views + + private var modalContent: some View { + 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 + statsCard + + // Play Again button + playAgainButton + } + .padding(cardPadding) + .background(modalBackground) + .shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge) + .frame(maxWidth: maxModalWidth) + .padding(.horizontal, Design.Spacing.xxLarge) + .scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink) + .opacity(showContent ? 1.0 : 0) + } + + private var statsCard: some View { + 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) + } + + private var playAgainButton: some View { + 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) + } + + private var modalBackground: some View { + 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 + ) + ) + } +} + +// MARK: - Previews + +#Preview("Game Over") { + GameOverView( + roundsPlayed: 42, + onPlayAgain: {} + ) +} + +#Preview("Few Rounds") { + GameOverView( + roundsPlayed: 3, + onPlayAgain: {} + ) +} + diff --git a/Baccarat/Baccarat/Views/ResultBannerView.swift b/Baccarat/Baccarat/Views/Sheets/ResultBannerView.swift similarity index 100% rename from Baccarat/Baccarat/Views/ResultBannerView.swift rename to Baccarat/Baccarat/Views/Sheets/ResultBannerView.swift diff --git a/Baccarat/Baccarat/Views/RulesHelpView.swift b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift similarity index 100% rename from Baccarat/Baccarat/Views/RulesHelpView.swift rename to Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift diff --git a/Baccarat/Baccarat/Views/SettingsView.swift b/Baccarat/Baccarat/Views/Sheets/SettingsView.swift similarity index 100% rename from Baccarat/Baccarat/Views/SettingsView.swift rename to Baccarat/Baccarat/Views/Sheets/SettingsView.swift diff --git a/Baccarat/Baccarat/Views/StatisticsSheetView.swift b/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift similarity index 100% rename from Baccarat/Baccarat/Views/StatisticsSheetView.swift rename to Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift diff --git a/Baccarat/Baccarat/Views/MiniBaccaratTableView.swift b/Baccarat/Baccarat/Views/Table/BettingTableView.swift similarity index 90% rename from Baccarat/Baccarat/Views/MiniBaccaratTableView.swift rename to Baccarat/Baccarat/Views/Table/BettingTableView.swift index 62ad753..42642ad 100644 --- a/Baccarat/Baccarat/Views/MiniBaccaratTableView.swift +++ b/Baccarat/Baccarat/Views/Table/BettingTableView.swift @@ -1,22 +1,41 @@ // -// MiniBaccaratTableView.swift +// BettingTableView.swift // Baccarat // -// A modern baccarat table layout with all betting options. +// The baccarat betting table layout with main bets and side bets. // import SwiftUI import CasinoKit /// The baccarat betting table layout with main bets and side bets. -struct MiniBaccaratTableView: View { +struct BettingTableView: View { @Bindable var gameState: GameState let selectedChip: ChipDenomination - // MARK: - Fixed Font Sizes + // MARK: - Environment + + @Environment(\.verticalSizeClass) private var verticalSizeClass + + /// Whether we're in landscape mode (compact vertical) + private var isLandscape: Bool { + verticalSizeClass == .compact + } + + // MARK: - Adaptive Sizes private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.small + /// Top bet row height - shorter in landscape + private var topRowHeight: CGFloat { + isLandscape ? 40 : Design.Size.topBetRowHeight + } + + /// Main bet row height - shorter in landscape + private var mainRowHeight: CGFloat { + isLandscape ? 50 : Design.Size.mainBetRowHeight + } + // MARK: - Computed Properties private func betAmount(for type: BetType) -> Int { @@ -49,6 +68,9 @@ struct MiniBaccaratTableView: View { ) } + // Use global debug flag from Design constants + private var showDebugBorders: Bool { Design.showDebugBorders } + // MARK: - Body var body: some View { @@ -60,6 +82,7 @@ struct MiniBaccaratTableView: View { .tracking(1) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.comfortable) + .debugBorder(showDebugBorders, color: .gray, label: "Limits") // Main betting table VStack(spacing: 0) { @@ -74,10 +97,12 @@ struct MiniBaccaratTableView: View { isBankerPairAtMax: isAtMax(for: .bankerPair), isTieAtMax: isAtMax(for: .tie), isPlayerPairAtMax: isAtMax(for: .playerPair), + rowHeight: topRowHeight, onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) }, onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) }, onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) } ) + .debugBorder(showDebugBorders, color: .purple, label: "TopRow") // Divider Rectangle() @@ -96,9 +121,11 @@ struct MiniBaccaratTableView: View { isMainAtMax: isAtMax(for: .banker), isBonusAtMax: isAtMax(for: .dragonBonusBanker), mainColor: Color.BettingZone.bankerDark, + rowHeight: mainRowHeight, onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) }, onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) } ) + .debugBorder(showDebugBorders, color: .red, label: "BankerRow") // Divider Rectangle() @@ -117,9 +144,11 @@ struct MiniBaccaratTableView: View { isMainAtMax: isAtMax(for: .player), isBonusAtMax: isAtMax(for: .dragonBonusPlayer), mainColor: Color.BettingZone.playerDark, + rowHeight: mainRowHeight, onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) }, onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) } ) + .debugBorder(showDebugBorders, color: .blue, label: "PlayerRow") } .clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large)) .overlay( @@ -135,6 +164,7 @@ struct MiniBaccaratTableView: View { ) .shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge) } + .debugBorder(showDebugBorders, color: .orange, label: "BettingTable") } } @@ -150,6 +180,7 @@ private struct TopBettingRow: View { let isBankerPairAtMax: Bool let isTieAtMax: Bool let isPlayerPairAtMax: Bool + let rowHeight: CGFloat let onBankerPair: () -> Void let onTie: () -> Void let onPlayerPair: () -> Void @@ -194,7 +225,7 @@ private struct TopBettingRow: View { action: onPlayerPair ) } - .frame(height: Design.Size.topBetRowHeight) + .frame(height: rowHeight) } } @@ -304,6 +335,7 @@ private struct MainBetRow: View { let isMainAtMax: Bool let isBonusAtMax: Bool let mainColor: Color + let rowHeight: CGFloat let onMain: () -> Void let onBonus: () -> Void @@ -335,7 +367,7 @@ private struct MainBetRow: View { ) .frame(width: Design.Size.bonusZoneWidth) } - .frame(height: Design.Size.mainBetRowHeight) + .frame(height: rowHeight) } } @@ -528,13 +560,31 @@ private struct ChipBadge: View { } } -#Preview { +// MARK: - Previews + +#Preview("Betting Table") { ZStack { - Color.Table.baseDark - .ignoresSafeArea() + TableBackgroundView() - MiniBaccaratTableView( - gameState: GameState(), + BettingTableView( + gameState: GameState(settings: GameSettings()), + selectedChip: .hundred + ) + .padding() + } +} + +#Preview("With Bets") { + ZStack { + TableBackgroundView() + + BettingTableView( + gameState: { + let state = GameState(settings: GameSettings()) + state.placeBet(type: .player, amount: 100) + state.placeBet(type: .tie, amount: 25) + return state + }(), selectedChip: .hundred ) .padding() diff --git a/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift b/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift new file mode 100644 index 0000000..ecca416 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift @@ -0,0 +1,246 @@ +// +// CardsDisplayArea.swift +// Baccarat +// +// The cards display area showing both Player and Banker hands. +// + +import SwiftUI +import CasinoKit + +/// 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 + /// Screen width for responsive card sizing + var screenWidth: CGFloat = 400 + + // MARK: - Environment + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + // MARK: - Computed Properties + + /// Whether we're on a large screen (iPad) + private var isLargeScreen: Bool { + horizontalSizeClass == .regular + } + + // Use global debug flag from Design constants + private var showDebugBorders: Bool { Design.showDebugBorders } + + /// Label font size - only scales on iPad to avoid clipping on small iPhones + private var labelFontSize: CGFloat { + let baseSize: CGFloat = 14 + return isLargeScreen ? baseSize * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseSize + } + + /// Minimum height for label row - only scales on iPad + private var labelRowMinHeight: CGFloat { + let baseHeight: CGFloat = 30 + return isLargeScreen ? baseHeight * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseHeight + } + + /// Spacing between PLAYER and BANKER hands - reduced on smaller screens + private var handsSpacing: CGFloat { + isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.small + } + + /// Horizontal padding inside the container - minimal on phones to maximize card size + private var containerPaddingH: CGFloat { + isLargeScreen ? Design.Spacing.xLarge : Design.Spacing.xSmall + } + + /// Outer horizontal padding - minimal on phones for edge-to-edge appearance + private var outerPaddingH: CGFloat { + isLargeScreen ? Design.Spacing.large : Design.Spacing.xSmall + } + + // 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) + } + + // MARK: - Body + + var body: some View { + HStack(spacing: handsSpacing) { + // Player side + playerHandSection + .debugBorder(showDebugBorders, color: .blue, label: "Player") + + // Banker side + bankerHandSection + .debugBorder(showDebugBorders, color: .red, label: "Banker") + } + .padding(.top, Design.Spacing.medium) + .padding(.bottom, Design.Spacing.large) + .padding(.horizontal, containerPaddingH) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) + .fill(Color.black.opacity(Design.Opacity.quarter)) + .accessibilityHidden(true) + ) + .padding(.horizontal, outerPaddingH) + .debugBorder(showDebugBorders, color: .mint, label: "HandsContainer") + } + + // MARK: - Private Views + + private var playerHandSection: some View { + 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) { + HandValueBadge(value: playerValue, color: .blue) + } + } + .frame(minHeight: labelRowMinHeight) + + // Cards + CompactHandView( + cards: playerCards, + cardsFaceUp: playerCardsFaceUp, + isWinner: playerIsWinner, + screenWidth: screenWidth + ) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Player hand")) + .accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : "")) + } + + private var bankerHandSection: some View { + 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) { + HandValueBadge(value: bankerValue, color: .red) + } + } + .frame(minHeight: labelRowMinHeight) + + // Cards + CompactHandView( + cards: bankerCards, + cardsFaceUp: bankerCardsFaceUp, + isWinner: bankerIsWinner, + screenWidth: screenWidth + ) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Banker hand")) + .accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : "")) + } +} + +// MARK: - Previews + +#Preview("Empty Hands") { + ZStack { + TableBackgroundView() + CardsDisplayArea( + playerCards: [], + bankerCards: [], + playerCardsFaceUp: [], + bankerCardsFaceUp: [], + playerValue: 0, + bankerValue: 0, + playerIsWinner: false, + bankerIsWinner: false, + isTie: false + ) + } +} + +#Preview("Player Wins") { + ZStack { + TableBackgroundView() + CardsDisplayArea( + playerCards: [ + Card(suit: .spades, rank: .king), + Card(suit: .hearts, rank: .eight) + ], + bankerCards: [ + Card(suit: .clubs, rank: .seven), + Card(suit: .diamonds, rank: .five) + ], + playerCardsFaceUp: [true, true], + bankerCardsFaceUp: [true, true], + playerValue: 8, + bankerValue: 2, + playerIsWinner: true, + bankerIsWinner: false, + isTie: false + ) + } +} + +#Preview("Banker Wins with 3 Cards") { + ZStack { + TableBackgroundView() + CardsDisplayArea( + playerCards: [ + Card(suit: .spades, rank: .four), + Card(suit: .hearts, rank: .three), + Card(suit: .clubs, rank: .two) + ], + bankerCards: [ + Card(suit: .diamonds, rank: .ace), + Card(suit: .spades, rank: .seven) + ], + playerCardsFaceUp: [true, true, true], + bankerCardsFaceUp: [true, true], + playerValue: 9, + bankerValue: 8, + playerIsWinner: false, + bankerIsWinner: true, + isTie: false + ) + } +} + diff --git a/Baccarat/Baccarat/Views/Table/CompactHandView.swift b/Baccarat/Baccarat/Views/Table/CompactHandView.swift new file mode 100644 index 0000000..68f45f9 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/CompactHandView.swift @@ -0,0 +1,202 @@ +// +// CompactHandView.swift +// Baccarat +// +// A compact view showing cards in a horizontal row with overlap. +// + +import SwiftUI +import CasinoKit + +/// A compact hand view showing cards in a row with overlap. +struct CompactHandView: View { + let cards: [Card] + let cardsFaceUp: [Bool] + let isWinner: Bool + /// Screen width passed from parent for responsive sizing + var screenWidth: CGFloat = 400 + + // MARK: - Environment + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + // MARK: - Computed Properties + + /// Whether we're on a large screen (iPad) + private var isLargeScreen: Bool { + horizontalSizeClass == .regular + } + + /// Whether we're on a small screen (iPhone SE, etc) + private var isSmallScreen: Bool { + !isLargeScreen && screenWidth < Design.Size.smallScreenThreshold + } + + /// WIN badge font size - only scales on iPad + private var winBadgeFontSize: CGFloat { + let baseSize: CGFloat = 10 + return isLargeScreen ? baseSize * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseSize + } + + /// Card width - responsive based on screen size + private var cardWidth: CGFloat { + if isLargeScreen { + return Design.Size.cardWidthTableLarge + } else if isSmallScreen { + return Design.Size.cardWidthTableSmall + } else { + return Design.Size.cardWidthTable + } + } + + /// Card height based on aspect ratio + private var cardHeight: CGFloat { + cardWidth * Design.Size.cardAspectRatio + } + + /// Card overlap - scaled with card size + private var cardOverlap: CGFloat { + if isLargeScreen { + return Design.Size.cardOverlapLarge + } else if isSmallScreen { + return Design.Size.cardOverlapSmall + } else { + return Design.Size.cardOverlap + } + } + + private let placeholderSpacing: CGFloat = Design.Spacing.small + + /// Fixed container width to prevent resizing during deal + private var fixedContainerWidth: CGFloat { + // Max 3 cards: first card full width + 2 more with overlap + let cardsWidth = cardWidth + (cardWidth + cardOverlap) * 2 + return cardsWidth + Design.Spacing.xSmall * 2 + } + + /// Fixed container height + private var fixedContainerHeight: CGFloat { + cardHeight + Design.Spacing.xSmall * 2 + } + + // MARK: - Body + + var body: some View { + ZStack { + // Fixed-size container + Color.clear + .frame(width: fixedContainerWidth, height: fixedContainerHeight) + + // Cards content centered in fixed container + cardsContent + } + .background(winnerBorder) + .overlay(alignment: .bottom) { + winBadge + } + } + + // MARK: - Private Views + + private var cardsContent: 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)) + } + } + } + } + + private var winnerBorder: some View { + RoundedRectangle(cornerRadius: Design.CornerRadius.small) + .strokeBorder( + isWinner ? Color.yellow : Color.clear, + lineWidth: Design.LineWidth.standard + ) + } + + @ViewBuilder + private var winBadge: some View { + 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) + } + } +} + +// MARK: - Previews + +#Preview("Empty Hand") { + ZStack { + TableBackgroundView() + CompactHandView( + cards: [], + cardsFaceUp: [], + isWinner: false + ) + } +} + +#Preview("Two Cards") { + ZStack { + TableBackgroundView() + CompactHandView( + cards: [ + Card(suit: .spades, rank: .king), + Card(suit: .hearts, rank: .eight) + ], + cardsFaceUp: [true, true], + isWinner: false + ) + } +} + +#Preview("Three Cards - Winner") { + ZStack { + TableBackgroundView() + CompactHandView( + cards: [ + Card(suit: .spades, rank: .four), + Card(suit: .hearts, rank: .three), + Card(suit: .clubs, rank: .two) + ], + cardsFaceUp: [true, true, true], + isWinner: true + ) + } +} + +#Preview("Cards Face Down") { + ZStack { + TableBackgroundView() + CompactHandView( + cards: [ + Card(suit: .diamonds, rank: .ace), + Card(suit: .spades, rank: .seven) + ], + cardsFaceUp: [false, false], + isWinner: false + ) + } +} + diff --git a/Baccarat/Baccarat/Views/Table/HandValueBadge.swift b/Baccarat/Baccarat/Views/Table/HandValueBadge.swift new file mode 100644 index 0000000..54217fc --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/HandValueBadge.swift @@ -0,0 +1,81 @@ +// +// HandValueBadge.swift +// Baccarat +// +// A circular badge displaying the hand value. +// + +import SwiftUI +import CasinoKit + +/// A small circular badge showing the hand value. +struct HandValueBadge: View { + let value: Int + let color: Color + + // MARK: - Environment + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + // MARK: - Computed Properties + + /// Whether we're on a large screen (iPad) + private var isLargeScreen: Bool { + horizontalSizeClass == .regular + } + + /// Scale factor for badge sizing - only applies on iPad to avoid clipping on iPhone + private var scale: CGFloat { + isLargeScreen ? Design.Size.handScale * Design.Size.largeScreenMultiplier : 1.0 + } + + @ScaledMetric(relativeTo: .headline) private var baseValueFontSize: CGFloat = 15 + @ScaledMetric(relativeTo: .headline) private var baseBadgeSize: CGFloat = 26 + + private var valueFontSize: CGFloat { baseValueFontSize * scale } + private var badgeSize: CGFloat { baseBadgeSize * scale } + + // MARK: - Body + + 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) + ) + } +} + +// MARK: - Previews + +#Preview("Player Value (Blue)") { + ZStack { + Color.Table.backgroundDark + HandValueBadge(value: 8, color: .blue) + } +} + +#Preview("Banker Value (Red)") { + ZStack { + Color.Table.backgroundDark + HandValueBadge(value: 5, color: .red) + } +} + +#Preview("Natural 9") { + ZStack { + Color.Table.backgroundDark + HandValueBadge(value: 9, color: .blue) + } +} + +#Preview("Zero Value") { + ZStack { + Color.Table.backgroundDark + HandValueBadge(value: 0, color: .red) + } +} + diff --git a/Baccarat/Baccarat/Views/Table/RoadMapGridView.swift b/Baccarat/Baccarat/Views/Table/RoadMapGridView.swift new file mode 100644 index 0000000..0e8e396 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/RoadMapGridView.swift @@ -0,0 +1,169 @@ +// +// RoadMapGridView.swift +// Baccarat +// +// A grid-based road map for landscape mode sidebar. +// Reads columns top-to-bottom, left-to-right (traditional baccarat style). +// + +import SwiftUI +import CasinoKit + +/// A grid-based road map for the landscape sidebar. +/// Results fill columns from top to bottom, then move right. +/// Rows are calculated dynamically based on available height. +struct RoadMapGridView: View { + let results: [RoundResult] + + /// Size of each dot + var dotSize: CGFloat = 32 + + /// Spacing between dots + var spacing: CGFloat = 10 + + /// Calculate number of rows that fit in given height + private func rowCount(for height: CGFloat) -> Int { + let availableHeight = height - (spacing * 2) // Account for padding + let cellHeight = dotSize + spacing + let count = Int(availableHeight / cellHeight) + return max(count, 1) // At least 1 row + } + + /// Arrange results into columns based on row count + private func columns(rows: Int) -> [[RoundResult]] { + guard rows > 0 else { return [] } + + var cols: [[RoundResult]] = [] + var currentCol: [RoundResult] = [] + + for result in results { + currentCol.append(result) + if currentCol.count >= rows { + cols.append(currentCol) + currentCol = [] + } + } + + // Add remaining items as last column + if !currentCol.isEmpty { + cols.append(currentCol) + } + + return cols + } + + var body: some View { + GeometryReader { geometry in + let rows = rowCount(for: geometry.size.height) + let cols = columns(rows: rows) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: spacing) { + ForEach(Array(cols.enumerated()), id: \.offset) { colIndex, column in + VStack(spacing: spacing) { + ForEach(Array(column.enumerated()), id: \.offset) { rowIndex, result in + GridDot( + result: result.result, + size: dotSize, + hasPair: result.hasPair, + isNatural: result.isNatural + ) + } + + // Fill remaining rows with empty space for alignment + if column.count < rows { + ForEach(0..<(rows - column.count), id: \.self) { _ in + Color.clear + .frame(width: dotSize, height: dotSize) + } + } + } + } + } + .padding(spacing) + } + } + } +} + +/// A compact dot for the grid display +private struct GridDot: View { + let result: GameResult + let size: CGFloat + var hasPair: Bool = false + var isNatural: Bool = false + + private var color: Color { + switch result { + case .playerWins: return .blue + case .bankerWins: return .red + case .tie: return .green + } + } + + private var label: String { + switch result { + case .playerWins: return "P" + case .bankerWins: return "B" + case .tie: return "T" + } + } + + var body: some View { + ZStack { + Circle() + .fill(color) + .frame(width: size, height: size) + + Text(label) + .font(.system(size: size * 0.5, weight: .bold)) + .foregroundStyle(.white) + + // Pair indicator (small yellow dot) + if hasPair { + Circle() + .fill(Color.yellow) + .frame(width: size * 0.25, height: size * 0.25) + .offset(x: -size * 0.3, y: size * 0.3) + } + + // Natural indicator (star) + if isNatural { + Image(systemName: "star.fill") + .font(.system(size: size * 0.25)) + .foregroundStyle(.yellow) + .offset(x: size * 0.3, y: -size * 0.3) + } + } + } +} + +// MARK: - Previews + +#Preview("Grid Road Map") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + RoadMapGridView( + results: [ + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7), + RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8), + RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9, bankerPair: true), + RoundResult(result: .tie, playerValue: 5, bankerValue: 5), + RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3), + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4, playerPair: true), + RoundResult(result: .tie, playerValue: 6, bankerValue: 6), + RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8) + ], + dotSize: 32 + ) + .frame(width: 240, height: 400) + .background(Color.black.opacity(0.3)) + } +} + diff --git a/Baccarat/Baccarat/Views/RoadMapView.swift b/Baccarat/Baccarat/Views/Table/RoadMapView.swift similarity index 100% rename from Baccarat/Baccarat/Views/RoadMapView.swift rename to Baccarat/Baccarat/Views/Table/RoadMapView.swift