diff --git a/Baccarat/Baccarat/Theme/DesignConstants.swift b/Baccarat/Baccarat/Theme/DesignConstants.swift index 397bb1a..055ca5c 100644 --- a/Baccarat/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Baccarat/Theme/DesignConstants.swift @@ -48,9 +48,12 @@ enum Design { static let mainBetRowHeight: CGFloat = 50 static let bonusZoneWidth: CGFloat = 80 - // Labels + // Labels (matches Blackjack for consistency) static let labelFontSize: CGFloat = 14 static let labelRowHeight: CGFloat = 30 + static let handLabelFontSize: CGFloat = 14 + static let handNumberFontSize: CGFloat = 12 + static let handIconSize: CGFloat = 18 // Buttons static let bettingButtonsContainerHeight: CGFloat = 70 diff --git a/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift b/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift index e8197f0..1411fe6 100644 --- a/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift +++ b/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift @@ -62,13 +62,13 @@ struct CardsDisplayArea: View { private var showDebugBorders: Bool { Design.showDebugBorders } - private var labelFontSize: CGFloat { - isLargeScreen ? 18 : Design.Size.labelFontSize - } + // MARK: - Scaled Metrics (Dynamic Type) + // These match Blackjack's HandLabelView for consistency - private var labelRowMinHeight: CGFloat { - isLargeScreen ? 40 : Design.Size.labelRowHeight - } + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize + @ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge + @ScaledMetric(relativeTo: .caption) private var betFontSize: CGFloat = Design.Size.handNumberFontSize + @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize /// Whether Player hand should be on bottom in vertical mode. private var playerOnBottom: Bool { @@ -225,15 +225,16 @@ struct CardsDisplayArea: View { // MARK: - Private Views /// Bet amount display shown below the bottom hand during dealing. + /// Matches Blackjack's PlayerHandView bet display styling. @ViewBuilder private var betAmountDisplay: some View { if totalBetAmount > 0 { HStack(spacing: Design.Spacing.xSmall) { Image(systemName: "dollarsign.circle.fill") - .font(.system(size: Design.BaseFontSize.xLarge)) + .font(.system(size: iconSize)) .foregroundStyle(.yellow) Text("\(totalBetAmount)") - .font(.system(size: Design.BaseFontSize.medium, weight: .bold, design: .rounded)) + .font(.system(size: betFontSize, weight: .bold, design: .rounded)) .foregroundStyle(.yellow) } .padding(.top, Design.Spacing.medium) @@ -265,7 +266,8 @@ struct CardsDisplayArea: View { .animation(nil, value: visibleValue) // No animation when value changes } } - .frame(minHeight: labelRowMinHeight) + .fixedSize() // Prevent the label from being constrained/truncated + .frame(minHeight: badgeHeight) CompactHandView( cards: playerCards, @@ -316,7 +318,8 @@ struct CardsDisplayArea: View { .animation(nil, value: visibleValue) // No animation when value changes } } - .frame(minHeight: labelRowMinHeight) + .fixedSize() // Prevent the label from being constrained/truncated + .frame(minHeight: badgeHeight) CompactHandView( cards: bankerCards, diff --git a/Baccarat/Baccarat/Views/Table/CompactHandView.swift b/Baccarat/Baccarat/Views/Table/CompactHandView.swift index 5751fb3..27ad6e9 100644 --- a/Baccarat/Baccarat/Views/Table/CompactHandView.swift +++ b/Baccarat/Baccarat/Views/Table/CompactHandView.swift @@ -86,6 +86,18 @@ struct CompactHandView: View { cards.isEmpty ? placeholderSpacing : cardSpacing } + /// The actual width of the cards content (for winner border sizing) + private var cardsContentWidth: CGFloat { + if cards.isEmpty { + // 2 placeholders with spacing + return (2 * placeholderWidth) + placeholderSpacing + } else { + // N cards with (N-1) spacings + let cardCount = CGFloat(cards.count) + return (cardCount * cardWidth) + ((cardCount - 1) * cardSpacing) + } + } + // MARK: - Body var body: some View { @@ -94,15 +106,13 @@ struct CompactHandView: View { if isLargeScreen || !isDealing { // iPad or betting phase: Simple centered layout - no scrolling needed - cardsContent - .padding(.horizontal, Design.Spacing.medium) + cardsContentWithBorder .frame(width: availableWidth, alignment: .center) } else { // iPhone dealing phase: Use ScrollView for 3rd card ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { - cardsContent - .padding(.horizontal, Design.Spacing.medium) + cardsContentWithBorder .frame(minWidth: availableWidth, alignment: .center) .id("cards_container") } @@ -131,17 +141,23 @@ struct CompactHandView: View { } } } - .frame(height: cardHeight) + .frame(height: cardHeight + (isWinner ? Design.Spacing.small * 2 : 0)) .frame(maxWidth: .infinity) - .padding(.vertical, isWinner ? Design.Spacing.small : 0) - .background(winnerBorder) - .overlay(alignment: .bottom) { - winBadge - } } // MARK: - Private Views + /// Cards content wrapped with the winner border sized to fit the actual cards + private var cardsContentWithBorder: some View { + cardsContent + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, isWinner ? Design.Spacing.small : 0) + .background(winnerBorder) + .overlay(alignment: .bottom) { + winBadge + } + } + private var cardsContent: some View { HStack(spacing: effectiveSpacing) { if cards.isEmpty { diff --git a/Baccarat/Baccarat/Views/Table/HandValueBadge.swift b/Baccarat/Baccarat/Views/Table/HandValueBadge.swift index 0ad1ec6..f4f1596 100644 --- a/Baccarat/Baccarat/Views/Table/HandValueBadge.swift +++ b/Baccarat/Baccarat/Views/Table/HandValueBadge.swift @@ -2,40 +2,25 @@ // HandValueBadge.swift // Baccarat // -// A circular badge displaying the hand value. +// A capsule badge displaying the hand value. +// Matches CasinoKit's ValueBadge styling for consistency with Blackjack. // import SwiftUI import CasinoKit -/// A small circular badge showing the hand value. +/// A capsule badge showing the hand value. +/// Uses the same styling as CasinoKit's ValueBadge for consistency across games. struct HandValueBadge: View { let value: Int let color: Color - // MARK: - Environment + // MARK: - Scaled Metrics (Dynamic Type) + // These match CasinoKit's ValueBadge exactly - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - // MARK: - Computed Properties - - /// Whether we're on a large screen (iPad) - private var isLargeScreen: Bool { - horizontalSizeClass == .regular - } - - @ScaledMetric(relativeTo: .headline) private var baseValueFontSize: CGFloat = 15 - @ScaledMetric(relativeTo: .headline) private var baseBadgeSize: CGFloat = 26 - - /// Font size - larger on iPad - private var valueFontSize: CGFloat { - isLargeScreen ? baseValueFontSize * 1.5 : baseValueFontSize - } - - /// Badge size - larger on iPad - private var badgeSize: CGFloat { - isLargeScreen ? baseBadgeSize * 1.5 : baseBadgeSize - } + @ScaledMetric(relativeTo: .headline) private var valueFontSize: CGFloat = 15 + @ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge + @ScaledMetric(relativeTo: .headline) private var badgePadding: CGFloat = 8 // MARK: - Body @@ -43,9 +28,12 @@ struct HandValueBadge: View { Text("\(value)") .font(.system(size: valueFontSize, weight: .black, design: .rounded)) .foregroundStyle(.white) - .frame(width: badgeSize, height: badgeSize) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, badgePadding) + .frame(minWidth: badgeHeight, minHeight: badgeHeight) .background( - Circle() + Capsule() .fill(color) ) } diff --git a/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift b/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift index 9471e13..9293a09 100644 --- a/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift +++ b/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift @@ -29,9 +29,6 @@ struct InteractiveCardView: View { isWaiting && !isFaceUp && isBottomHand && revealStyle != .auto && !isInteracting } - /// Animation state for the pulsing glow - @State private var glowPulse = false - /// Tracks if we just completed a squeeze reveal (to skip flip animation) @State private var squeezeJustCompleted = false @@ -44,21 +41,18 @@ struct InteractiveCardView: View { // Glow layer - completely separate from the card, sits underneath GlowBackgroundView( isActive: showGlow, - glowPulse: glowPulse, + shouldPulse: showGlow, cardWidth: cardWidth ) + // Force recreation on size change to avoid stale dimensions during rotation + .id("glow-\(Int(cardWidth))") // Card layer - completely independent, no glow modifiers cardContent } - .onAppear { - if showGlow { - glowPulse = true - } - } - .onChange(of: showGlow) { _, newValue in - glowPulse = newValue - } + // Explicit frame ensures card and glow stay synced during size changes + .frame(width: cardWidth, height: cardHeight) + .animation(.easeOut(duration: 0.3), value: cardWidth) .onChange(of: isWaiting) { _, newValue in // Reset interaction state when this card is no longer waiting if !newValue { @@ -154,9 +148,12 @@ private struct VerticalFlipCardView: View { /// Completely separate from the card view to avoid any state changes affecting the card. private struct GlowBackgroundView: View { let isActive: Bool - let glowPulse: Bool + let shouldPulse: Bool let cardWidth: CGFloat + /// Internal animation state - starts false so animation triggers on appear + @State private var animatingPulse = false + /// Corner radius matching CasinoKit's CardView private var cornerRadius: CGFloat { cardWidth * 0.08 @@ -170,15 +167,15 @@ private struct GlowBackgroundView: View { ZStack { // Outer glow strokes (largest to smallest for layered glow) RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color.orange.opacity(glowPulse ? 0.6 : 0.2), lineWidth: glowPulse ? 20 : 10) + .stroke(Color.orange.opacity(animatingPulse ? 0.6 : 0.2), lineWidth: animatingPulse ? 20 : 10) .blur(radius: 10) RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color.yellow.opacity(glowPulse ? 0.8 : 0.3), lineWidth: glowPulse ? 12 : 6) + .stroke(Color.yellow.opacity(animatingPulse ? 0.8 : 0.3), lineWidth: animatingPulse ? 12 : 6) .blur(radius: 6) RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color.yellow.opacity(glowPulse ? 1.0 : 0.5), lineWidth: glowPulse ? 6 : 3) + .stroke(Color.yellow.opacity(animatingPulse ? 1.0 : 0.5), lineWidth: animatingPulse ? 6 : 3) .blur(radius: 3) // Bright animated border (sharp, on top) @@ -189,16 +186,25 @@ private struct GlowBackgroundView: View { startPoint: .topLeading, endPoint: .bottomTrailing ), - lineWidth: glowPulse ? 4 : 3 + lineWidth: animatingPulse ? 4 : 3 ) } .frame(width: cardWidth, height: cardHeight) - .scaleEffect(isActive && glowPulse ? 1.02 : 1.0) + .scaleEffect(isActive && animatingPulse ? 1.02 : 1.0) .opacity(isActive ? 1.0 : 0.0) .animation(.easeOut(duration: 0.15), value: isActive) .animation( isActive ? .easeInOut(duration: 0.7).repeatForever(autoreverses: true) : .easeOut(duration: 0.15), - value: glowPulse + value: animatingPulse ) + .onAppear { + // Start animation after view appears - this ensures the repeating animation triggers + if shouldPulse { + animatingPulse = true + } + } + .onChange(of: shouldPulse) { _, newValue in + animatingPulse = newValue + } } } diff --git a/Baccarat/Baccarat/Views/Table/PageCurlView.swift b/Baccarat/Baccarat/Views/Table/PageCurlView.swift index 681700e..1794c15 100644 --- a/Baccarat/Baccarat/Views/Table/PageCurlView.swift +++ b/Baccarat/Baccarat/Views/Table/PageCurlView.swift @@ -26,6 +26,8 @@ struct PageCurlView: View { var body: some View { PageCurlRepresentable( currentIndex: $currentIndex, + width: width, + height: height, onReveal: onReveal, onInteractionStarted: onInteractionStarted, pages: [ @@ -48,12 +50,15 @@ struct PageCurlView: View { ) ]) .frame(width: width, height: height) - .id("page-curl-\(card.id)") + // Use both card ID and size to force recreation on rotation + .id("page-curl-\(card.id)-\(Int(width))") } } private struct PageCurlRepresentable: UIViewControllerRepresentable { @Binding var currentIndex: Int + let width: CGFloat + let height: CGFloat let onReveal: () -> Void let onInteractionStarted: (() -> Void)? let pages: [AnyView] @@ -84,7 +89,10 @@ private struct PageCurlRepresentable: UIViewControllerRepresentable { return pageVC } - func updateUIViewController(_ pageVC: UIPageViewController, context: Context) { } + func updateUIViewController(_ pageVC: UIPageViewController, context: Context) { + // Update the page view controller's preferred content size when dimensions change + pageVC.preferredContentSize = CGSize(width: width, height: height) + } func makeCoordinator() -> Coordinator { Coordinator(self) diff --git a/Blackjack/Blackjack/Engine/GameState.swift b/Blackjack/Blackjack/Engine/GameState.swift index 4560686..b0c6fe0 100644 --- a/Blackjack/Blackjack/Engine/GameState.swift +++ b/Blackjack/Blackjack/Engine/GameState.swift @@ -435,7 +435,7 @@ final class GameState: CasinoGameState { currentBet += amount balance -= amount - sound.play(.chipPlace) + sound.playChipPlace() } /// Places a side bet (Perfect Pairs or 21+3). @@ -452,7 +452,7 @@ final class GameState: CasinoGameState { twentyOnePlusThreeBet += amount } balance -= amount - sound.play(.chipPlace) + sound.playChipPlace() } /// Clears all bets (main and side bets). @@ -461,7 +461,7 @@ final class GameState: CasinoGameState { currentBet = 0 perfectPairsBet = 0 twentyOnePlusThreeBet = 0 - sound.play(.chipPlace) + sound.playClearBets() } /// Total amount bet (main + side bets). @@ -538,7 +538,7 @@ final class GameState: CasinoGameState { } else { dealerHand.cards.append(card) } - sound.play(.cardDeal) + sound.playCardDeal() // Wait for card to appear on screen if cardAppearDelay > 0 { @@ -593,7 +593,7 @@ final class GameState: CasinoGameState { if playerBJ || dealerBJ { // Reveal dealer card - sound.play(.cardFlip) + sound.playCardFlip() if playerBJ && dealerBJ { // Push @@ -625,11 +625,11 @@ final class GameState: CasinoGameState { insuranceBet = insuranceAmount balance -= insuranceAmount - sound.play(.chipPlace) + sound.playChipPlace() // Check dealer blackjack if dealerHand.isBlackjack { - sound.play(.cardFlip) + sound.playCardFlip() // Insurance wins let payout = insuranceBet * 3 balance += payout @@ -672,7 +672,7 @@ final class GameState: CasinoGameState { guard let card = engine.dealCard() else { return } playerHands[activeHandIndex].cards.append(card) - sound.play(.cardDeal) + sound.playCardDeal() // Animation timing let animationDuration = Design.Animation.springDuration * settings.dealingSpeed @@ -733,12 +733,12 @@ final class GameState: CasinoGameState { let additionalBet = playerHands[activeHandIndex].bet balance -= additionalBet playerHands[activeHandIndex].isDoubledDown = true - sound.play(.chipPlace) + sound.playChipPlace() // Deal one card and stand if let card = engine.dealCard() { playerHands[activeHandIndex].cards.append(card) - sound.play(.cardDeal) + sound.playCardDeal() // Animation timing let animationDuration = Design.Animation.springDuration * settings.dealingSpeed @@ -792,7 +792,7 @@ final class GameState: CasinoGameState { // Deduct bet for second hand balance -= originalHand.bet - sound.play(.chipPlace) + sound.playChipPlace() // Replace original with split hands first (so visible counts are tracked correctly) playerHands.remove(at: activeHandIndex) @@ -818,7 +818,7 @@ final class GameState: CasinoGameState { // Deal one card to each hand (with full animation timing for each) if let card1 = engine.dealCard() { playerHands[activeHandIndex].cards.append(card1) - sound.play(.cardDeal) + sound.playCardDeal() if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) @@ -831,7 +831,7 @@ final class GameState: CasinoGameState { if let card2 = engine.dealCard() { playerHands[activeHandIndex + 1].cards.append(card2) - sound.play(.cardDeal) + sound.playCardDeal() if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) @@ -911,7 +911,7 @@ final class GameState: CasinoGameState { dealerHand.cards.append(card) // Mark card as visible immediately - face is visible as soon as card appears dealerVisibleCardCount += 1 - sound.play(.cardDeal) + sound.playCardDeal() // Wait for animation to complete before checking blackjack if delay > 0 { @@ -938,7 +938,7 @@ final class GameState: CasinoGameState { } else { // American style: reveal hole card (card is already in hand) // The flip animation shows the card face at the midpoint (90° rotation) - sound.play(.cardFlip) + sound.playCardFlip() // Wait until card face becomes visible (halfway through flip) if flipMidpointDelay > 0 { @@ -962,7 +962,7 @@ final class GameState: CasinoGameState { dealerHand.cards.append(card) // Mark card as visible immediately - face is visible as soon as card appears dealerVisibleCardCount += 1 - sound.play(.cardDeal) + sound.playCardDeal() // Wait for animation to complete before drawing next card if delay > 0 { @@ -1006,7 +1006,7 @@ final class GameState: CasinoGameState { let ppWon = perfectPairsResult?.isWin ?? false let topWon = twentyOnePlusThreeResult?.isWin ?? false if ppWon || topWon { - sound.play(.win) + sound.playWin() } // Auto-hide toasts after delay @@ -1186,13 +1186,13 @@ final class GameState: CasinoGameState { // Save game data to iCloud saveGameData() - // Play appropriate sound + // Play appropriate sound with haptic feedback if roundWinnings > 0 { - sound.play(.win) + sound.playWin() } else if roundWinnings < 0 { - sound.play(.lose) + sound.playLose() } else { - sound.play(.push) + sound.playPush() } // Reset bet for next round @@ -1249,7 +1249,7 @@ final class GameState: CasinoGameState { lastRoundResult = nil currentPhase = .betting - sound.play(.newRound) + sound.playNewRound() } // MARK: - SessionManagedGame Implementation @@ -1366,28 +1366,28 @@ final class GameState: CasinoGameState { // Deal player card 1 playerHands[0].cards.append(card1) - sound.play(.cardDeal) + sound.playCardDeal() if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } playerHandsVisibleCardCount[0] += 1 if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) } // Deal dealer card 1 (face up) dealerHand.cards.append(dealerCard1) - sound.play(.cardDeal) + sound.playCardDeal() if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } dealerVisibleCardCount += 1 if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) } // Deal player card 2 (matching rank for split) playerHands[0].cards.append(card2) - sound.play(.cardDeal) + sound.playCardDeal() if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } playerHandsVisibleCardCount[0] += 1 if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) } // Deal dealer hole card (face down) dealerHand.cards.append(dealerCard2) - sound.play(.cardDeal) + sound.playCardDeal() if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) } dealerVisibleCardCount += 1 if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) }