Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-25 08:32:07 -06:00
parent 8d222317a0
commit 100eb83422
7 changed files with 118 additions and 94 deletions

View File

@ -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

View File

@ -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,

View File

@ -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 {

View File

@ -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)
)
}

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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)) }