CasinoGames/Blackjack/Blackjack/Views/Table/CardStackView.swift

253 lines
8.1 KiB
Swift

//
// CardStackView.swift
// Blackjack
//
// Shared card stack display for dealer and player hands.
//
import SwiftUI
import CasinoKit
/// A reusable view that displays a stack of cards with animations.
/// Used by both DealerHandView and PlayerHandView.
struct CardStackView: View {
let cards: [Card]
let cardWidth: CGFloat
let cardSpacing: CGFloat
let showAnimations: Bool
let dealingSpeed: Double
let showCardCount: Bool
/// Determines if a card at a given index should be face up.
/// For dealer: `{ index in index == 0 || showHoleCard }`
/// For player: `{ _ in true }`
let isFaceUp: (Int) -> Bool
/// Animation offset for dealing cards (direction cards fly in from).
let dealOffset: CGPoint
/// Minimum number of card slots to reserve space for.
/// This prevents width changes during dealing by pre-allocating placeholder space.
let minimumCardSlots: Int
/// Whether placeholders should use staggered (overlapped) layout like dealt cards.
/// - `true`: Placeholders overlap using `cardSpacing` (use for bordered containers like player hand)
/// - `false`: Placeholders side-by-side (use for borderless containers like dealer hand)
let staggeredPlaceholders: Bool
/// Number of additional placeholders needed to reach minimum slots.
private var placeholdersNeeded: Int {
max(0, minimumCardSlots - cards.count)
}
/// Spacing to use for empty placeholder state
private var placeholderSpacing: CGFloat {
staggeredPlaceholders ? cardSpacing : Design.Spacing.small
}
var body: some View {
// Use staggered or side-by-side spacing based on configuration
HStack(spacing: cards.isEmpty && placeholdersNeeded > 0 ? placeholderSpacing : cardSpacing) {
if cards.isEmpty && placeholdersNeeded > 0 {
// Show placeholders when empty
ForEach(0..<placeholdersNeeded, id: \.self) { index in
CardPlaceholderView(width: cardWidth)
.zIndex(Double(index))
}
} else {
// Show actual cards
ForEach(cards.indices, id: \.self) { index in
let faceUp = isFaceUp(index)
CardView(
card: cards[index],
isFaceUp: faceUp,
cardWidth: cardWidth
)
.overlay(alignment: .bottomLeading) {
if showCardCount && faceUp {
HiLoCountBadge(card: cards[index])
}
}
.zIndex(Double(index))
.transition(
showAnimations
? .asymmetric(
insertion: .offset(x: dealOffset.x, y: dealOffset.y)
.combined(with: .opacity)
.combined(with: .scale(scale: Design.Scale.slightShrink)),
removal: .scale.combined(with: .opacity)
)
: .identity
)
}
// Show trailing placeholders to reserve space for upcoming cards
ForEach(0..<placeholdersNeeded, id: \.self) { index in
CardPlaceholderView(width: cardWidth)
.opacity(Design.Opacity.medium)
.zIndex(Double(cards.count + index))
}
}
}
.animation(
showAnimations
? CasinoDesign.Animation.cardDeal(speed: dealingSpeed)
: .none,
value: cards.count
)
}
}
// MARK: - Convenience Initializers
extension CardStackView {
/// Creates a card stack for the dealer (with hole card support).
/// Uses side-by-side placeholders since dealer area has no border.
static func dealer(
cards: [Card],
showHoleCard: Bool,
cardWidth: CGFloat,
cardSpacing: CGFloat,
showAnimations: Bool,
dealingSpeed: Double,
showCardCount: Bool
) -> CardStackView {
CardStackView(
cards: cards,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardCount: showCardCount,
isFaceUp: { index in index == 0 || showHoleCard },
dealOffset: CGPoint(
x: Design.DealAnimation.dealerOffsetX,
y: Design.DealAnimation.dealerOffsetY
),
minimumCardSlots: 2,
staggeredPlaceholders: false // Side-by-side (no border to show shrink)
)
}
/// Creates a card stack for the player (all cards face up).
/// Uses staggered placeholders to match dealt card layout (prevents container shrink).
/// - Parameter minimumCardSlots: Minimum slots to reserve (default 2 for initial deal).
static func player(
cards: [Card],
cardWidth: CGFloat,
cardSpacing: CGFloat,
showAnimations: Bool,
dealingSpeed: Double,
showCardCount: Bool,
minimumCardSlots: Int = 2
) -> CardStackView {
CardStackView(
cards: cards,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardCount: showCardCount,
isFaceUp: { _ in true },
dealOffset: CGPoint(
x: Design.DealAnimation.playerOffsetX,
y: Design.DealAnimation.playerOffsetY
),
minimumCardSlots: minimumCardSlots,
staggeredPlaceholders: true // Staggered (bordered container shows shrink)
)
}
}
// MARK: - Previews
#Preview("Empty - Staggered") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView(
cards: [],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: false,
isFaceUp: { _ in true },
dealOffset: .zero,
minimumCardSlots: 2,
staggeredPlaceholders: true
)
}
}
#Preview("Empty - Side by Side") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView(
cards: [],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: false,
isFaceUp: { _ in true },
dealOffset: .zero,
minimumCardSlots: 2,
staggeredPlaceholders: false
)
}
}
#Preview("Player Cards") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.player(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}
#Preview("Dealer - Hole Hidden") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.dealer(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
showHoleCard: false,
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}
#Preview("Dealer - Hole Revealed") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.dealer(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
showHoleCard: true,
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}