253 lines
8.1 KiB
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
|
|
)
|
|
}
|
|
}
|
|
|