412 lines
15 KiB
Swift
412 lines
15 KiB
Swift
//
|
|
// CardsDisplayArea.swift
|
|
// Baccarat
|
|
//
|
|
// The cards display area showing both Player and Banker hands.
|
|
// Animates from side-by-side (betting) to vertical stack (dealing).
|
|
//
|
|
|
|
import SwiftUI
|
|
import CasinoKit
|
|
|
|
/// The cards display area showing both hands.
|
|
/// - Betting phase: Horizontal side-by-side layout (Player | Banker)
|
|
/// - Dealing phase: Vertical stack with betted hand on bottom
|
|
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
|
|
let showAnimations: Bool
|
|
let dealingSpeed: Double
|
|
let revealStyle: RevealStyle
|
|
let isWaitingForReveal: Bool
|
|
let currentRevealIndex: Int
|
|
let revealProgress: Double
|
|
/// Which main bet is placed - nil if no main bet, true if Player, false if Banker.
|
|
let bettedOnPlayer: Bool?
|
|
/// Whether the game is in dealing/result phase (vertical layout) or betting phase (horizontal).
|
|
let isDealing: Bool
|
|
/// Full screen size for calculating card width in dealing mode.
|
|
let screenSize: CGSize
|
|
let onReveal: () -> Void
|
|
let onUpdateProgress: (Double) -> Void
|
|
|
|
// MARK: - State
|
|
|
|
@State private var containerWidth: CGFloat = 300
|
|
@Namespace private var animation
|
|
|
|
// MARK: - Environment
|
|
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var isLargeScreen: Bool {
|
|
horizontalSizeClass == .regular
|
|
}
|
|
|
|
/// Whether we're in landscape mode
|
|
private var isLandscape: Bool {
|
|
verticalSizeClass == .compact || (isLargeScreen && screenSize.width > screenSize.height)
|
|
}
|
|
|
|
private var showDebugBorders: Bool { Design.showDebugBorders }
|
|
|
|
private var labelFontSize: CGFloat {
|
|
isLargeScreen ? 18 : Design.Size.labelFontSize
|
|
}
|
|
|
|
private var labelRowMinHeight: CGFloat {
|
|
isLargeScreen ? 40 : Design.Size.labelRowHeight
|
|
}
|
|
|
|
/// Whether Player hand should be on bottom in vertical mode.
|
|
private var playerOnBottom: Bool {
|
|
bettedOnPlayer ?? true
|
|
}
|
|
|
|
/// Spacing between hands
|
|
private var handsSpacing: CGFloat {
|
|
if isDealing {
|
|
return isLargeScreen ? Design.Spacing.large : Design.Spacing.medium
|
|
} else {
|
|
return isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.large
|
|
}
|
|
}
|
|
|
|
// MARK: - Card Width Calculations
|
|
|
|
/// Card section width for horizontal (betting) mode
|
|
private var horizontalHandWidth: CGFloat {
|
|
let spacing = isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.large
|
|
return max(100, (containerWidth - spacing) / 2)
|
|
}
|
|
|
|
/// Card section width for vertical (dealing) mode - matches Blackjack sizing
|
|
/// Uses screen height as base (like Blackjack), which is smaller in landscape
|
|
private var verticalHandWidth: CGFloat {
|
|
// Use screen height (smaller dimension in landscape = smaller cards)
|
|
let height = screenSize.height
|
|
guard height > 100 else { return horizontalHandWidth }
|
|
|
|
// Blackjack uses 0.18, but in landscape we may need slightly smaller
|
|
let percentage: CGFloat = isLandscape ? 0.175 : height < 700 ? 0.14 : 0.18
|
|
let cardWidth = height * percentage
|
|
|
|
// CompactHandView: cardWidth = containerWidth / divisor
|
|
let overlapRatio: CGFloat = -0.45
|
|
let maxCards: CGFloat = 3
|
|
let divisor = 1 + (maxCards - 1) * (1 + overlapRatio)
|
|
return cardWidth * divisor
|
|
}
|
|
|
|
/// Current hand section width based on mode
|
|
private var handSectionWidth: CGFloat {
|
|
isDealing ? verticalHandWidth : horizontalHandWidth
|
|
}
|
|
|
|
// 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 {
|
|
// Use different layouts but keep view identity with matchedGeometryEffect
|
|
Group {
|
|
if isDealing {
|
|
// Vertical layout with flexible spacing between hands
|
|
VStack(spacing: 0) {
|
|
if playerOnBottom {
|
|
bankerHandSection(width: handSectionWidth)
|
|
.matchedGeometryEffect(id: "banker", in: animation)
|
|
|
|
// Flexible spacer expands on taller screens
|
|
Spacer(minLength: handsSpacing)
|
|
|
|
playerHandSection(width: handSectionWidth)
|
|
.matchedGeometryEffect(id: "player", in: animation)
|
|
} else {
|
|
playerHandSection(width: handSectionWidth)
|
|
.matchedGeometryEffect(id: "player", in: animation)
|
|
|
|
// Flexible spacer expands on taller screens
|
|
Spacer(minLength: handsSpacing)
|
|
|
|
bankerHandSection(width: handSectionWidth)
|
|
.matchedGeometryEffect(id: "banker", in: animation)
|
|
}
|
|
}
|
|
} else {
|
|
// Horizontal layout - Player left, Banker right
|
|
HStack(spacing: handsSpacing) {
|
|
playerHandSection(width: handSectionWidth)
|
|
.matchedGeometryEffect(id: "player", in: animation)
|
|
bankerHandSection(width: handSectionWidth)
|
|
.matchedGeometryEffect(id: "banker", in: animation)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, Design.Spacing.medium)
|
|
.padding(.bottom, Design.Spacing.large)
|
|
.background(
|
|
GeometryReader { geometry in
|
|
Color.clear
|
|
.onAppear {
|
|
containerWidth = geometry.size.width * 0.95
|
|
}
|
|
.onChange(of: geometry.size.width) { _, newWidth in
|
|
containerWidth = newWidth * 0.95
|
|
}
|
|
}
|
|
)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
|
|
.fill(Color.black.opacity(isWaitingForReveal ? 0.05 : Design.Opacity.quarter))
|
|
.accessibilityHidden(true)
|
|
)
|
|
.debugBorder(showDebugBorders, color: .mint, label: "HandsContainer")
|
|
.animation(.spring(duration: 0.6, bounce: 0.2), value: isDealing)
|
|
.animation(.spring(duration: 0.5, bounce: 0.15), value: playerOnBottom)
|
|
}
|
|
|
|
// MARK: - Private Views
|
|
|
|
private func playerHandSection(width: CGFloat) -> some View {
|
|
// Calculate value from face-up cards
|
|
let visibleValue: Int = {
|
|
guard !playerCards.isEmpty else { return 0 }
|
|
let faceUpCards = zip(playerCards, playerCardsFaceUp)
|
|
.filter { $1 }
|
|
.map { $0.0 }
|
|
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
}()
|
|
|
|
let allCardsFaceUp = !playerCards.isEmpty && playerCardsFaceUp.count == playerCards.count && playerCardsFaceUp.allSatisfy({ $0 })
|
|
let _ = CasinoDesign.debugLog("🎯 Player: cards=\(playerCards.count), faceUp=\(playerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
|
|
|
|
return VStack(spacing: Design.Spacing.small) {
|
|
HStack(spacing: Design.Spacing.small) {
|
|
Text("PLAYER")
|
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
// Always show value when there are cards - it updates as cards become visible
|
|
if !playerCards.isEmpty {
|
|
HandValueBadge(value: visibleValue, color: .blue)
|
|
.animation(nil, value: visibleValue) // No animation when value changes
|
|
}
|
|
}
|
|
.frame(minHeight: labelRowMinHeight)
|
|
|
|
CompactHandView(
|
|
cards: playerCards,
|
|
cardsFaceUp: playerCardsFaceUp,
|
|
isWinner: playerIsWinner,
|
|
containerWidth: width,
|
|
showAnimations: showAnimations,
|
|
dealingSpeed: dealingSpeed,
|
|
revealStyle: revealStyle,
|
|
isWaitingForReveal: isWaitingForReveal,
|
|
currentRevealIndex: currentRevealIndex,
|
|
revealProgress: revealProgress,
|
|
isPlayerHand: true,
|
|
onReveal: onReveal,
|
|
onUpdateProgress: onUpdateProgress
|
|
)
|
|
}
|
|
.frame(maxWidth: isDealing ? .infinity : width)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(String(localized: "Player hand"))
|
|
.accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : ""))
|
|
}
|
|
|
|
private func bankerHandSection(width: CGFloat) -> some View {
|
|
// Calculate value from face-up cards
|
|
let visibleValue: Int = {
|
|
guard !bankerCards.isEmpty else { return 0 }
|
|
let faceUpCards = zip(bankerCards, bankerCardsFaceUp)
|
|
.filter { $1 }
|
|
.map { $0.0 }
|
|
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
}()
|
|
|
|
let allCardsFaceUp = !bankerCards.isEmpty && bankerCardsFaceUp.count == bankerCards.count && bankerCardsFaceUp.allSatisfy({ $0 })
|
|
let _ = CasinoDesign.debugLog("🎯 Banker: cards=\(bankerCards.count), faceUp=\(bankerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
|
|
|
|
return VStack(spacing: Design.Spacing.small) {
|
|
HStack(spacing: Design.Spacing.small) {
|
|
Text("BANKER")
|
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
// Always show value when there are cards - it updates as cards become visible
|
|
if !bankerCards.isEmpty {
|
|
HandValueBadge(value: visibleValue, color: .red)
|
|
.animation(nil, value: visibleValue) // No animation when value changes
|
|
}
|
|
}
|
|
.frame(minHeight: labelRowMinHeight)
|
|
|
|
CompactHandView(
|
|
cards: bankerCards,
|
|
cardsFaceUp: bankerCardsFaceUp,
|
|
isWinner: bankerIsWinner,
|
|
containerWidth: width,
|
|
showAnimations: showAnimations,
|
|
dealingSpeed: dealingSpeed,
|
|
revealStyle: revealStyle,
|
|
isWaitingForReveal: isWaitingForReveal,
|
|
currentRevealIndex: currentRevealIndex,
|
|
revealProgress: revealProgress,
|
|
isPlayerHand: false,
|
|
onReveal: onReveal,
|
|
onUpdateProgress: onUpdateProgress
|
|
)
|
|
}
|
|
.frame(maxWidth: isDealing ? .infinity : width)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(String(localized: "Banker hand"))
|
|
.accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : ""))
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
#Preview("Horizontal - Betting Phase") {
|
|
ZStack {
|
|
TableBackgroundView()
|
|
CardsDisplayArea(
|
|
playerCards: [],
|
|
bankerCards: [],
|
|
playerCardsFaceUp: [],
|
|
bankerCardsFaceUp: [],
|
|
playerValue: 0,
|
|
bankerValue: 0,
|
|
playerIsWinner: false,
|
|
bankerIsWinner: false,
|
|
isTie: false,
|
|
showAnimations: true,
|
|
dealingSpeed: 1.0,
|
|
revealStyle: .auto,
|
|
isWaitingForReveal: false,
|
|
currentRevealIndex: 0,
|
|
revealProgress: 0,
|
|
bettedOnPlayer: nil,
|
|
isDealing: false,
|
|
screenSize: CGSize(width: 400, height: 800),
|
|
onReveal: {},
|
|
onUpdateProgress: { _ in }
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Vertical - Dealing (Bet on Player)") {
|
|
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,
|
|
showAnimations: true,
|
|
dealingSpeed: 1.0,
|
|
revealStyle: .auto,
|
|
isWaitingForReveal: false,
|
|
currentRevealIndex: 0,
|
|
revealProgress: 0,
|
|
bettedOnPlayer: true,
|
|
isDealing: true,
|
|
screenSize: CGSize(width: 400, height: 800),
|
|
onReveal: {},
|
|
onUpdateProgress: { _ in }
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Vertical - Dealing (Bet on Banker)") {
|
|
ZStack {
|
|
TableBackgroundView()
|
|
CardsDisplayArea(
|
|
playerCards: [
|
|
Card(suit: .spades, rank: .four),
|
|
Card(suit: .hearts, rank: .three),
|
|
Card(suit: .clubs, rank: .two)
|
|
],
|
|
bankerCards: [
|
|
Card(suit: .hearts, rank: .ace),
|
|
Card(suit: .diamonds, rank: .ace),
|
|
Card(suit: .spades, rank: .seven)
|
|
],
|
|
playerCardsFaceUp: [true, true, true],
|
|
bankerCardsFaceUp: [true, true, true],
|
|
playerValue: 9,
|
|
bankerValue: 9,
|
|
playerIsWinner: false,
|
|
bankerIsWinner: true,
|
|
isTie: false,
|
|
showAnimations: true,
|
|
dealingSpeed: 1.0,
|
|
revealStyle: .auto,
|
|
isWaitingForReveal: false,
|
|
currentRevealIndex: 0,
|
|
revealProgress: 0,
|
|
bettedOnPlayer: false,
|
|
isDealing: true,
|
|
screenSize: CGSize(width: 400, height: 800),
|
|
onReveal: {},
|
|
onUpdateProgress: { _ in }
|
|
)
|
|
}
|
|
}
|